init projectmycelium
This commit is contained in:
402
src/static/css/styles.css
Normal file
402
src/static/css/styles.css
Normal file
@@ -0,0 +1,402 @@
|
||||
/* Custom styles for ThreeFold Marketplace */
|
||||
|
||||
/* Global styles */
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-top: 56px; /* Height of fixed navbar */
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Ensure navbar has highest z-index */
|
||||
.navbar {
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-item {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-main {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* Improve button rendering inside Bootstrap modals to avoid hover flicker */
|
||||
.modal .btn {
|
||||
will-change: transform;
|
||||
transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-control:focus {
|
||||
border-color: #80bdff;
|
||||
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
/* Marketplace styles */
|
||||
.sidebar {
|
||||
min-height: calc(100vh - 112px); /* Account for navbar and footer */
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 1rem;
|
||||
position: sticky;
|
||||
top: 56px; /* Height of the navbar */
|
||||
}
|
||||
|
||||
/* Sidebar toggle button for mobile */
|
||||
.sidebar-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 76px; /* Return to original position */
|
||||
left: 15px;
|
||||
z-index: 1045; /* Higher than sidebar */
|
||||
padding: 8px 12px;
|
||||
width: auto;
|
||||
max-width: 100px; /* Limit maximum width */
|
||||
background-color: #0d6efd; /* Bootstrap primary color */
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-toggle:hover, .sidebar-toggle:focus {
|
||||
background-color: #0b5ed7;
|
||||
color: white;
|
||||
outline: none;
|
||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.sidebar-toggle .bi {
|
||||
font-size: 1.2rem;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
color: #333;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #0d6efd;
|
||||
font-weight: 600;
|
||||
border-left-color: #0d6efd;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
}
|
||||
|
||||
.marketplace-item {
|
||||
height: 100%;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.marketplace-item:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-0.25rem);
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-category {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.gateway-status-indicator {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.service-item {
|
||||
height: 100%;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
transition: box-shadow 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.service-item:hover {
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* CTA Buttons styles */
|
||||
.cta-primary {
|
||||
font-size: 1.2rem;
|
||||
padding: 0.75rem 2rem;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.cta-secondary {
|
||||
font-size: 1.2rem;
|
||||
padding: 0.75rem 2rem;
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Home page styles */
|
||||
.hero-section {
|
||||
padding: 5rem 0;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
}
|
||||
|
||||
/* When modal is open, suppress background hover transitions to avoid backdrop repaint flicker */
|
||||
body.modal-open .card:hover,
|
||||
body.modal-open .marketplace-item:hover,
|
||||
body.modal-open .service-item:hover,
|
||||
body.modal-open .table-hover tbody tr:hover {
|
||||
transform: none !important;
|
||||
box-shadow: inherit !important;
|
||||
background-color: inherit !important;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.marketplace-preview {
|
||||
padding: 4rem 0;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.faq-section {
|
||||
padding: 4rem 0;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.faq-question {
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
background-color: #fff;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.faq-answer {
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0 0 0.25rem 0.25rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Legal pages styles */
|
||||
.legal-content {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.legal-content h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.legal-content h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Media queries for responsive layout */
|
||||
@media (max-width: 767.98px) {
|
||||
/* Mobile sidebar approach - completely different approach */
|
||||
.sidebar {
|
||||
display: none; /* Hidden by default instead of off-screen */
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 66px; /* Back to original position */
|
||||
width: 80%;
|
||||
max-width: 280px;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 66px); /* Original calculation */
|
||||
overflow-y: auto; /* Enable scrolling within sidebar */
|
||||
z-index: 1040;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
padding: 7rem 1rem 1.25rem 1rem; /* Substantially more top padding */
|
||||
border-right: 1px solid #dee2e6;
|
||||
pointer-events: auto !important; /* Ensure clicks work */
|
||||
}
|
||||
|
||||
/* When sidebar is shown */
|
||||
.sidebar.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Enhanced mobile sidebar styles */
|
||||
.sidebar .nav-link {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
background-color: rgba(13, 110, 253, 0.1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
background-color: rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
|
||||
.sidebar .sidebar-heading {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
position: relative; /* For positioning consistency */
|
||||
}
|
||||
|
||||
/* Position the first heading down from the top */
|
||||
.sidebar .position-sticky > h5.sidebar-heading:first-of-type {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Add extra space above all sidebar content */
|
||||
.sidebar .position-sticky {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
.sidebar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 66px; /* Slightly taller navbar on mobile */
|
||||
}
|
||||
|
||||
/* Fix for CTA buttons being too close to logo in mobile view */
|
||||
.hero-section .d-flex.flex-wrap {
|
||||
margin-bottom: 2rem; /* Add spacing between CTA buttons and logo */
|
||||
}
|
||||
|
||||
/* Fix spacing for dashboard welcome page */
|
||||
.row.mb-5.align-items-center .col-md-6:first-child {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.d-grid.gap-3 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Add overlay when sidebar is shown */
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 66px; /* Back to original position */
|
||||
left: 0; /* Start at left edge */
|
||||
width: 100%; /* Full width */
|
||||
height: calc(100vh - 66px); /* Original calculation */
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
z-index: 1030; /* Lower than sidebar */
|
||||
}
|
||||
|
||||
/* When backdrop is shown */
|
||||
.sidebar-backdrop.show {
|
||||
display: block;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1035; /* Between sidebar and toggle button */
|
||||
}
|
||||
|
||||
/* Ensure the sidebar can be clicked */
|
||||
.sidebar.show {
|
||||
display: block;
|
||||
z-index: 1040; /* Above backdrop */
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Ensure links in sidebar can be clicked */
|
||||
.sidebar.show .nav-link {
|
||||
pointer-events: auto !important;
|
||||
position: relative;
|
||||
z-index: 1045;
|
||||
}
|
||||
|
||||
/* Adjust main content when sidebar is toggled */
|
||||
.main-content-wrapper {
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure Bootstrap modal always appears above custom navbar/sidebar/backdrops */
|
||||
.modal-backdrop {
|
||||
z-index: 1070 !important; /* Above navbar (1050) and sidebar overlay (1035/1040) */
|
||||
}
|
||||
|
||||
.modal {
|
||||
z-index: 1080 !important; /* Above backdrop */
|
||||
}
|
71
src/static/debug.html
Normal file
71
src/static/debug.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Debug Page</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
button {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Debug Page</h1>
|
||||
|
||||
<h2>Client-Side Cookies</h2>
|
||||
<pre id="cookies"></pre>
|
||||
|
||||
<h2>Debug API Response</h2>
|
||||
<button id="fetchDebug">Fetch Debug Info</button>
|
||||
<pre id="debugInfo"></pre>
|
||||
|
||||
<script>
|
||||
// Display client-side cookies
|
||||
function displayCookies() {
|
||||
const cookiesDiv = document.getElementById('cookies');
|
||||
const cookies = document.cookie.split(';').map(cookie => cookie.trim());
|
||||
|
||||
if (cookies.length === 0 || (cookies.length === 1 && cookies[0] === '')) {
|
||||
cookiesDiv.textContent = 'No cookies found';
|
||||
} else {
|
||||
const cookieObj = {};
|
||||
cookies.forEach(cookie => {
|
||||
const [name, value] = cookie.split('=');
|
||||
cookieObj[name] = value;
|
||||
});
|
||||
cookiesDiv.textContent = JSON.stringify(cookieObj, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch debug info from API
|
||||
document.getElementById('fetchDebug').addEventListener('click', async () => {
|
||||
try {
|
||||
const response = await fetch('/debug');
|
||||
const data = await response.json();
|
||||
document.getElementById('debugInfo').textContent = JSON.stringify(data, null, 2);
|
||||
} catch (error) {
|
||||
document.getElementById('debugInfo').textContent = `Error: ${error.message}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Initial display
|
||||
displayCookies();
|
||||
|
||||
// Update cookies display every 2 seconds
|
||||
setInterval(displayCookies, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
81
src/static/images/docs/tfp-flow.svg
Normal file
81
src/static/images/docs/tfp-flow.svg
Normal file
@@ -0,0 +1,81 @@
|
||||
<svg width="800" height="550" viewBox="0 0 800 550" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Define styles -->
|
||||
<defs>
|
||||
<style>
|
||||
.box { fill: #ffffff; stroke: #4B5563; stroke-width: 2; rx: 8; ry: 8; }
|
||||
.text { font-family: 'Poppins', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 14px; fill: #1F2937; text-anchor: middle; }
|
||||
.title-text { font-size: 16px; font-weight: bold; }
|
||||
.arrow-line { stroke: #6B7280; stroke-width: 2; }
|
||||
.arrow-head { fill: #6B7280; }
|
||||
/* .label-text class is no longer used for arrow labels */
|
||||
.provider-color { fill: #D1FAE5; stroke: #059669; } /* Light Green */
|
||||
.user-color { fill: #DBEAFE; stroke: #2563EB; } /* Light Blue */
|
||||
.system-color { fill: #FEF3C7; stroke: #D97706; } /* Light Yellow */
|
||||
.tfp-color { fill: #E0E7FF; stroke: #4F46E5; } /* Light Indigo for TFP itself */
|
||||
.marketplace-color { fill: #F3E8FF; stroke: #7E22CE; } /* Light Purple */
|
||||
</style>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" class="arrow-head" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text x="400" y="30" class="text title-text" style="font-size: 20px;">ThreeFold Points (TFP) Flow</text>
|
||||
|
||||
<!-- Column 1: Providers & Generation -->
|
||||
<rect x="50" y="70" width="180" height="100" class="box provider-color" />
|
||||
<text x="140" y="100" class="text title-text">Providers</text>
|
||||
<text x="140" y="120" class="text">Contribute Resources</text>
|
||||
<text x="140" y="135" class="text">& Services to Grid</text>
|
||||
|
||||
<rect x="50" y="200" width="180" height="70" class="box system-color" />
|
||||
<text x="140" y="230" class="text title-text">TFP Generation</text>
|
||||
<text x="140" y="250" class="text">(Minted for Providers)</text>
|
||||
|
||||
<rect x="50" y="300" width="180" height="70" class="box tfp-color" />
|
||||
<text x="140" y="330" class="text title-text">Provider TFP</text>
|
||||
<text x="140" y="350" class="text">Balance</text>
|
||||
|
||||
<!-- Column 2: Marketplace -->
|
||||
<rect x="310" y="230" width="180" height="100" class="box marketplace-color" />
|
||||
<text x="400" y="260" class="text title-text">Marketplace</text>
|
||||
<text x="400" y="280" class="text">Exchange of</text>
|
||||
<text x="400" y="295" class="text">Services/Resources</text>
|
||||
<text x="400" y="310" class="text">for TFP</text>
|
||||
|
||||
<!-- Column 3: Users & Acquisition -->
|
||||
<rect x="570" y="70" width="180" height="100" class="box user-color" />
|
||||
<text x="660" y="115" class="text title-text">Users</text>
|
||||
|
||||
<rect x="570" y="200" width="180" height="70" class="box system-color" />
|
||||
<text x="660" y="225" class="text title-text">TFP Acquisition</text>
|
||||
<text x="660" y="240" class="text">(Fiat, Swaps,</text>
|
||||
<text x="660" y="255" class="text">Liquidity Pools)</text>
|
||||
|
||||
<rect x="570" y="300" width="180" height="70" class="box tfp-color" />
|
||||
<text x="660" y="330" class="text title-text">User TFP</text>
|
||||
<text x="660" y="350" class="text">Balance</text>
|
||||
|
||||
<!-- Arrows (No Labels) -->
|
||||
<!-- Provider to TFP Generation -->
|
||||
<line x1="140" y1="170" x2="140" y2="200" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- TFP Generation to Provider Balance -->
|
||||
<line x1="140" y1="270" x2="140" y2="300" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- User to TFP Acquisition -->
|
||||
<line x1="660" y1="170" x2="660" y2="200" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- TFP Acquisition to User Balance -->
|
||||
<line x1="660" y1="270" x2="660" y2="300" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- User Balance to Marketplace -->
|
||||
<line x1="570" y1="335" x2="490" y2="305" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- Marketplace to Provider Balance -->
|
||||
<line x1="310" y1="305" x2="230" y2="335" class="arrow-line" marker-end="url(#arrow)" />
|
||||
|
||||
<!-- Circulation Loop (Conceptual) -->
|
||||
<path d="M 230 370 Q 250 470, 400 470 Q 550 470, 570 370" fill="none" class="arrow-line" stroke-dasharray="5,5" />
|
||||
<text x="400" y="490" class="text title-text" text-anchor="middle">TFP Circulates in Ecosystem</text>
|
||||
</svg>
|
After Width: | Height: | Size: 4.2 KiB |
1
src/static/images/gitea-logo.svg
Normal file
1
src/static/images/gitea-logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 32C132.3 32 32 132.3 32 256s100.3 224 224 224 224-100.3 224-224S379.7 32 256 32zm-32 256c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm128 0c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32zm-64-96c0 17.7-14.3 32-32 32s-32-14.3-32-32 14.3-32 32-32 32 14.3 32 32z" fill="#609926"/></svg>
|
After Width: | Height: | Size: 397 B |
BIN
src/static/images/logo_dark.png
Normal file
BIN
src/static/images/logo_dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 909 B |
BIN
src/static/images/logo_light.png
Normal file
BIN
src/static/images/logo_light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 947 B |
BIN
src/static/images/logo_name_dark.png
Normal file
BIN
src/static/images/logo_name_dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
BIN
src/static/images/logo_name_light.png
Normal file
BIN
src/static/images/logo_name_light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
33
src/static/js/auth-forms.js
Normal file
33
src/static/js/auth-forms.js
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Authentication forms functionality (login/register)
|
||||
* Migrated from inline scripts to use apiJson and shared error handlers
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if coming from checkout flow
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const fromCheckout = urlParams.get('checkout') === 'true';
|
||||
const returnUrl = urlParams.get('return');
|
||||
|
||||
if (fromCheckout) {
|
||||
// Show message about completing checkout after login/registration
|
||||
const message = document.querySelector('.auth-checkout-message');
|
||||
if (message) {
|
||||
message.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submissions
|
||||
const authForm = document.querySelector('form[action*="/login"], form[action*="/register"]');
|
||||
if (authForm) {
|
||||
authForm.addEventListener('submit', async function(e) {
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
setButtonLoading(submitBtn, 'Processing...');
|
||||
}
|
||||
|
||||
// Let the form submit normally for now
|
||||
// This maintains existing server-side validation and redirect logic
|
||||
});
|
||||
}
|
||||
});
|
353
src/static/js/base.js
Normal file
353
src/static/js/base.js
Normal file
@@ -0,0 +1,353 @@
|
||||
/* eslint-disable no-console */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Global fetch 402 interceptor (centralized insufficient funds handling)
|
||||
(function setupFetch402Interceptor() {
|
||||
try {
|
||||
if (!window.fetch || window.__fetch402InterceptorInstalled) return;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async function (...args) {
|
||||
const response = await originalFetch(...args);
|
||||
try {
|
||||
if (response && response.status === 402 && window.Errors && typeof window.Errors.handleInsufficientFundsResponse === 'function') {
|
||||
// Throttle duplicate modals fired by multiple concurrent requests
|
||||
const now = Date.now();
|
||||
const last = window.__lastInsufficientFundsTs || 0;
|
||||
if (now - last > 3000) {
|
||||
window.__lastInsufficientFundsTs = now;
|
||||
// Use a clone so callers can still read the original response body
|
||||
const clone = response.clone();
|
||||
const text = await clone.text();
|
||||
await window.Errors.handleInsufficientFundsResponse(clone, text);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Fetch 402 interceptor error:', e);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
window.__fetch402InterceptorInstalled = true;
|
||||
} catch (e) {
|
||||
console.error('Failed to setup fetch 402 interceptor:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
// Shared API JSON helper: standardized fetch + JSON unwrap + error handling
|
||||
async function apiJson(input, init = {}) {
|
||||
const opts = { ...init };
|
||||
|
||||
// Normalize headers and set defaults
|
||||
const headers = new Headers(init && init.headers ? init.headers : {});
|
||||
if (!headers.has('Accept')) headers.set('Accept', 'application/json');
|
||||
opts.headers = headers;
|
||||
|
||||
// Default credentials to same-origin unless explicitly set
|
||||
if (!('credentials' in opts)) {
|
||||
opts.credentials = 'same-origin';
|
||||
}
|
||||
|
||||
// If body is a plain object and not FormData, assume JSON
|
||||
const isPlainObjectBody = opts.body && typeof opts.body === 'object' && !(opts.body instanceof FormData);
|
||||
if (isPlainObjectBody) {
|
||||
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
|
||||
if (headers.get('Content-Type') && headers.get('Content-Type').includes('application/json')) {
|
||||
opts.body = JSON.stringify(opts.body);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(input, opts);
|
||||
|
||||
// Best-effort body read. Consumers don't need the raw response body.
|
||||
let text = '';
|
||||
try {
|
||||
text = await res.text();
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
let parsed = null;
|
||||
if (text && text.trim().length) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_) {
|
||||
parsed = null; // non-JSON response
|
||||
}
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
// 204/empty -> null
|
||||
if (!parsed) return null;
|
||||
// Unwrap standardized API envelope: { data, success, message, ... }
|
||||
const data = (parsed && typeof parsed === 'object' && 'data' in parsed) ? (parsed.data ?? parsed) : parsed;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Not OK -> throw informative error
|
||||
const message = (parsed && typeof parsed === 'object' && (parsed.message || parsed.error)) || res.statusText || 'Request failed';
|
||||
const err = new Error(message);
|
||||
// Attach useful context
|
||||
err.status = res.status;
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
err.errors = parsed.errors;
|
||||
err.data = parsed.data;
|
||||
err.metadata = parsed.metadata;
|
||||
}
|
||||
err.body = text;
|
||||
throw err;
|
||||
}
|
||||
window.apiJson = apiJson;
|
||||
|
||||
// Enhanced API helpers for comprehensive request handling
|
||||
|
||||
// FormData helper with consistent error handling
|
||||
window.apiFormData = async (url, formData, options = {}) => {
|
||||
const opts = {
|
||||
...options,
|
||||
method: options.method || 'POST',
|
||||
body: formData
|
||||
};
|
||||
return apiJson(url, opts);
|
||||
};
|
||||
|
||||
// Text response helper (PDFs, plain text, etc.)
|
||||
window.apiText = async (url, options = {}) => {
|
||||
const startTime = performance.now();
|
||||
const opts = {
|
||||
...options,
|
||||
credentials: options.credentials || 'same-origin'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, opts);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log performance in development
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.log(`🌐 API Text: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
const error = new Error(`${response.status}: ${response.statusText}`);
|
||||
error.status = response.status;
|
||||
error.body = text;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.text();
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.error(`❌ API Text Failed: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Blob helper for binary downloads
|
||||
window.apiBlob = async (url, options = {}) => {
|
||||
const startTime = performance.now();
|
||||
const opts = {
|
||||
...options,
|
||||
credentials: options.credentials || 'same-origin'
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(url, opts);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.log(`📁 API Blob: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`${response.status}: ${response.statusText}`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.blob();
|
||||
} catch (error) {
|
||||
const duration = performance.now() - startTime;
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.error(`❌ API Blob Failed: ${opts.method || 'GET'} ${url} (${duration.toFixed(0)}ms)`, error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Request deduplication to prevent double submissions
|
||||
const pendingRequests = new Map();
|
||||
|
||||
window.apiJsonDeduped = async (url, options = {}) => {
|
||||
const key = `${options.method || 'GET'}:${url}:${JSON.stringify(options.body || {})}`;
|
||||
|
||||
if (pendingRequests.has(key)) {
|
||||
return pendingRequests.get(key);
|
||||
}
|
||||
|
||||
const promise = apiJson(url, options).finally(() => {
|
||||
pendingRequests.delete(key);
|
||||
});
|
||||
|
||||
pendingRequests.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
// Enhanced apiJson with performance logging and retry logic
|
||||
const originalApiJson = apiJson;
|
||||
window.apiJson = async (url, options = {}) => {
|
||||
const startTime = performance.now();
|
||||
const method = options.method || 'GET';
|
||||
const maxRetries = options.retries || (method === 'GET' ? 2 : 0); // Only retry GET requests by default
|
||||
const retryDelay = options.retryDelay || 1000; // 1 second delay between retries
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await originalApiJson(url, options);
|
||||
const duration = performance.now() - startTime;
|
||||
|
||||
// Log performance in development
|
||||
if (window.location.hostname === 'localhost') {
|
||||
const retryInfo = attempt > 0 ? ` (retry ${attempt})` : '';
|
||||
console.log(`🚀 API: ${method} ${url} (${duration.toFixed(0)}ms)${retryInfo}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Don't retry on client errors (4xx) or authentication issues
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't retry on the last attempt
|
||||
if (attempt === maxRetries) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only retry on network errors or server errors (5xx)
|
||||
const isRetryable = !error.status || error.status >= 500;
|
||||
if (!isRetryable) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before retrying
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.warn(`⚠️ API Retry: ${method} ${url} (attempt ${attempt + 1}/${maxRetries + 1})`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime;
|
||||
if (window.location.hostname === 'localhost') {
|
||||
console.error(`❌ API Failed: ${method} ${url} (${duration.toFixed(0)}ms)`, lastError);
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
// Global cart count update function
|
||||
async function updateCartCount() {
|
||||
try {
|
||||
const cartNavItem = document.getElementById('cartNavItem');
|
||||
const cartCountElement = document.querySelector('.cart-count');
|
||||
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const itemCount = (cartData && typeof cartData === 'object') ? (parseInt(cartData.item_count) || 0) : 0;
|
||||
|
||||
if (cartNavItem) {
|
||||
if (itemCount > 0) {
|
||||
// Show cart nav item and update count
|
||||
cartNavItem.style.display = 'block';
|
||||
if (cartCountElement) {
|
||||
cartCountElement.textContent = itemCount;
|
||||
cartCountElement.style.display = 'inline';
|
||||
}
|
||||
} else {
|
||||
// Hide cart nav item when empty or zero
|
||||
cartNavItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating cart count:', error);
|
||||
// Hide cart nav item on error
|
||||
const cartNavItem = document.getElementById('cartNavItem');
|
||||
if (cartNavItem) {
|
||||
cartNavItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
window.updateCartCount = updateCartCount;
|
||||
|
||||
// Global helper to emit cartUpdated events consistently
|
||||
window.emitCartUpdated = function (cartCount) {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('cartUpdated', {
|
||||
detail: { cartCount: typeof cartCount === 'number' ? cartCount : undefined }
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to dispatch cartUpdated event:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep navbar in sync with cart updates
|
||||
window.addEventListener('cartUpdated', function () {
|
||||
if (typeof updateCartCount === 'function') {
|
||||
updateCartCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Navbar dropdown data loader
|
||||
async function loadNavbarData() {
|
||||
try {
|
||||
const data = await window.apiJson('/api/navbar/dropdown-data', { cache: 'no-store' }) || {};
|
||||
if (data.wallet_balance_formatted) {
|
||||
// Update navbar balance display
|
||||
const navbarBalance = document.getElementById('navbar-balance');
|
||||
const dropdownBalance = document.getElementById('dropdown-balance');
|
||||
const currencyIndicator = document.getElementById('dropdown-currency-indicator');
|
||||
|
||||
if (navbarBalance) {
|
||||
navbarBalance.textContent = data.wallet_balance_formatted;
|
||||
}
|
||||
if (dropdownBalance) {
|
||||
dropdownBalance.textContent = data.wallet_balance_formatted;
|
||||
}
|
||||
if (currencyIndicator && data.display_currency) {
|
||||
currencyIndicator.textContent = data.display_currency;
|
||||
}
|
||||
} else {
|
||||
// Missing expected fields; apply fallback
|
||||
const dropdownBalance = document.getElementById('dropdown-balance');
|
||||
if (dropdownBalance) dropdownBalance.textContent = 'N/A';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load navbar data:', error);
|
||||
// Fallback to showing basic info
|
||||
const navbarBalance = document.getElementById('navbar-balance');
|
||||
const dropdownBalance = document.getElementById('dropdown-balance');
|
||||
|
||||
if (navbarBalance) {
|
||||
navbarBalance.textContent = 'Wallet';
|
||||
}
|
||||
if (dropdownBalance) {
|
||||
dropdownBalance.textContent = 'N/A';
|
||||
}
|
||||
}
|
||||
}
|
||||
window.loadNavbarData = loadNavbarData;
|
||||
window.updateNavbarBalance = async function () { await loadNavbarData(); };
|
||||
|
||||
// Initializers
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (typeof updateCartCount === 'function') {
|
||||
updateCartCount();
|
||||
}
|
||||
// Only try to load navbar data if dropdown elements exist
|
||||
if (document.getElementById('dropdown-balance')) {
|
||||
loadNavbarData();
|
||||
}
|
||||
});
|
||||
})();
|
226
src/static/js/buy-now.js
Normal file
226
src/static/js/buy-now.js
Normal file
@@ -0,0 +1,226 @@
|
||||
// Buy Now functionality with builder pattern and modal system integration
|
||||
class BuyNowRequestBuilder {
|
||||
constructor() {
|
||||
this.request = {};
|
||||
}
|
||||
|
||||
productId(id) {
|
||||
this.request.product_id = id;
|
||||
return this;
|
||||
}
|
||||
|
||||
productName(name) {
|
||||
this.request.product_name = name;
|
||||
return this;
|
||||
}
|
||||
|
||||
productCategory(category) {
|
||||
this.request.product_category = category || 'general';
|
||||
return this;
|
||||
}
|
||||
|
||||
quantity(qty) {
|
||||
this.request.quantity = qty || 1;
|
||||
return this;
|
||||
}
|
||||
|
||||
unitPriceUsd(price) {
|
||||
this.request.unit_price_usd = parseFloat(price);
|
||||
return this;
|
||||
}
|
||||
|
||||
providerId(id) {
|
||||
this.request.provider_id = id || 'marketplace';
|
||||
return this;
|
||||
}
|
||||
|
||||
providerName(name) {
|
||||
this.request.provider_name = name || 'Project Mycelium';
|
||||
return this;
|
||||
}
|
||||
|
||||
specifications(specs) {
|
||||
this.request.specifications = specs;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
// Validate required fields
|
||||
if (!this.request.product_id) {
|
||||
throw new Error('Product ID is required');
|
||||
}
|
||||
if (!this.request.product_name) {
|
||||
throw new Error('Product name is required');
|
||||
}
|
||||
if (!this.request.unit_price_usd || this.request.unit_price_usd <= 0) {
|
||||
throw new Error('Valid unit price is required');
|
||||
}
|
||||
|
||||
return { ...this.request };
|
||||
}
|
||||
}
|
||||
|
||||
class BuyNowManager {
|
||||
constructor() {
|
||||
this.initializeEventHandlers();
|
||||
}
|
||||
|
||||
static builder() {
|
||||
return new BuyNowRequestBuilder();
|
||||
}
|
||||
|
||||
initializeEventHandlers() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.buy-now-btn').forEach(button => {
|
||||
button.addEventListener('click', (e) => this.handleBuyNow(e));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async handleBuyNow(event) {
|
||||
const button = event.target.closest('.buy-now-btn');
|
||||
|
||||
// Disable button during processing
|
||||
button.disabled = true;
|
||||
const originalText = button.innerHTML;
|
||||
button.innerHTML = '<i class="spinner-border spinner-border-sm me-1"></i>Processing...';
|
||||
|
||||
try {
|
||||
// Build purchase request using builder pattern
|
||||
const purchaseRequest = BuyNowManager.builder()
|
||||
.productId(button.dataset.productId)
|
||||
.productName(button.dataset.productName)
|
||||
.productCategory(button.dataset.category)
|
||||
.quantity(parseInt(button.dataset.quantity) || 1)
|
||||
.unitPriceUsd(button.dataset.unitPrice)
|
||||
.providerId(button.dataset.providerId)
|
||||
.providerName(button.dataset.providerName)
|
||||
.specifications(button.dataset.specifications ? JSON.parse(button.dataset.specifications) : null)
|
||||
.build();
|
||||
|
||||
// Check authentication first
|
||||
const isAuthenticated = await this.checkAuthentication();
|
||||
if (!isAuthenticated) {
|
||||
this.showAuthRequired();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check affordability using total cost (price * quantity)
|
||||
const totalRequired = (Number(purchaseRequest.unit_price_usd) || 0) * (Number(purchaseRequest.quantity) || 1);
|
||||
const affordabilityData = await window.apiJson(`/api/wallet/check-affordability?amount=${totalRequired}`);
|
||||
|
||||
if (!affordabilityData.can_afford) {
|
||||
// Show insufficient balance modal
|
||||
this.showInsufficientBalance(affordabilityData.shortfall_info?.shortfall || purchaseRequest.unit_price_usd);
|
||||
return;
|
||||
}
|
||||
|
||||
// Proceed with instant purchase
|
||||
const purchasePayload = await window.apiJson('/api/wallet/instant-purchase', {
|
||||
method: 'POST',
|
||||
body: purchaseRequest
|
||||
});
|
||||
|
||||
if (purchasePayload && purchasePayload.success) {
|
||||
this.showSuccess(`Successfully purchased ${purchaseRequest.product_name}!`);
|
||||
// Update navbar balance
|
||||
if (window.loadNavbarData) {
|
||||
window.loadNavbarData();
|
||||
}
|
||||
// Refresh orders page if it exists
|
||||
if (window.refreshOrders) {
|
||||
window.refreshOrders();
|
||||
}
|
||||
} else {
|
||||
// Handle canonical error envelope even if 200 OK with success=false
|
||||
if (window.Errors && typeof window.Errors.isInsufficientFundsEnvelope === 'function' && window.Errors.isInsufficientFundsEnvelope(purchasePayload)) {
|
||||
try {
|
||||
const details = window.Errors.extractInsufficientFundsDetails(purchasePayload);
|
||||
window.Errors.renderInsufficientFunds(details);
|
||||
return;
|
||||
} catch (_) { /* fall through to generic error */ }
|
||||
}
|
||||
const message = (purchasePayload && purchasePayload.message) || 'An error occurred during purchase';
|
||||
this.showError('Purchase Failed', message);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Buy Now error:', error);
|
||||
if (error && error.status === 402) {
|
||||
// Global interceptor will show modal
|
||||
return;
|
||||
}
|
||||
if (String(error && error.message || '').includes('required')) {
|
||||
this.showError('Invalid Product Data', error.message);
|
||||
} else {
|
||||
this.showError('Purchase Failed', error && error.message ? error.message : 'Purchase failed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
// Re-enable button
|
||||
button.disabled = false;
|
||||
button.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
if (window.modalSystem) {
|
||||
window.modalSystem.showSuccess('Purchase Successful', message);
|
||||
} else if (window.showNotification) {
|
||||
window.showNotification(message, 'success');
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
showError(title, message) {
|
||||
if (window.modalSystem) {
|
||||
window.modalSystem.showError(title, message);
|
||||
} else if (window.showNotification) {
|
||||
window.showNotification(message, 'error');
|
||||
} else {
|
||||
alert(message);
|
||||
}
|
||||
}
|
||||
|
||||
async checkAuthentication() {
|
||||
try {
|
||||
const result = await window.apiJson('/api/auth/status', { credentials: 'include' });
|
||||
const isAuthenticated = (result && result.authenticated === true) || false;
|
||||
return isAuthenticated;
|
||||
} catch (error) {
|
||||
console.error('Authentication check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
showAuthRequired() {
|
||||
if (window.modalSystem) {
|
||||
window.modalSystem.showAuthRequired();
|
||||
} else {
|
||||
const userChoice = confirm(
|
||||
'Please log in or register to make purchases. Would you like to go to the dashboard to continue?'
|
||||
);
|
||||
if (userChoice) {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showInsufficientBalance(shortfall) {
|
||||
if (window.modalSystem) {
|
||||
window.modalSystem.showInsufficientBalance(shortfall);
|
||||
} else {
|
||||
const userChoice = confirm(
|
||||
`Insufficient balance. You need $${shortfall.toFixed(2)} more. ` +
|
||||
`Would you like to go to the wallet to add credits?`
|
||||
);
|
||||
|
||||
if (userChoice) {
|
||||
window.location.href = '/dashboard/wallet?action=topup';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize Buy Now manager
|
||||
new BuyNowManager();
|
324
src/static/js/cart-marketplace.js
Normal file
324
src/static/js/cart-marketplace.js
Normal file
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Cart functionality for marketplace pages
|
||||
* Migrated from inline scripts to use apiJson and shared error handlers
|
||||
*/
|
||||
|
||||
// Global variable to store product ID for removal
|
||||
let productIdToRemove = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize cart functionality
|
||||
console.log('Cart page loaded');
|
||||
|
||||
// Update My Orders visibility when cart page loads
|
||||
if (typeof updateMyOrdersVisibility === 'function') {
|
||||
updateMyOrdersVisibility();
|
||||
}
|
||||
|
||||
// Initialize recommended products functionality
|
||||
initializeRecommendedProducts();
|
||||
|
||||
// Add event listeners for quantity buttons
|
||||
document.querySelectorAll('[data-action="increase"], [data-action="decrease"]').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const productId = this.getAttribute('data-product-id');
|
||||
const action = this.getAttribute('data-action');
|
||||
const currentQuantity = parseInt(this.parentElement.querySelector('span').textContent);
|
||||
|
||||
if (action === 'increase') {
|
||||
updateQuantity(productId, currentQuantity + 1);
|
||||
} else if (action === 'decrease') {
|
||||
updateQuantity(productId, currentQuantity - 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listeners for remove buttons
|
||||
document.querySelectorAll('[data-action="remove"]').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const productId = this.getAttribute('data-product-id');
|
||||
showRemoveItemModal(productId);
|
||||
});
|
||||
});
|
||||
|
||||
// Add event listener for clear cart button
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (clearCartBtn) {
|
||||
clearCartBtn.addEventListener('click', showClearCartModal);
|
||||
}
|
||||
|
||||
// Add event listener for confirm clear cart button in modal
|
||||
const confirmClearCartBtn = document.getElementById('confirmClearCartBtn');
|
||||
if (confirmClearCartBtn) {
|
||||
confirmClearCartBtn.addEventListener('click', clearCart);
|
||||
}
|
||||
|
||||
// Add event listener for confirm remove item button in modal
|
||||
const confirmRemoveItemBtn = document.getElementById('confirmRemoveItemBtn');
|
||||
if (confirmRemoveItemBtn) {
|
||||
confirmRemoveItemBtn.addEventListener('click', confirmRemoveItem);
|
||||
}
|
||||
|
||||
// Add event listener for currency selector
|
||||
const currencySelector = document.getElementById('currencySelector');
|
||||
if (currencySelector) {
|
||||
currencySelector.addEventListener('change', changeCurrency);
|
||||
}
|
||||
|
||||
// Post-reload success toast for cart clear (marketplace view)
|
||||
try {
|
||||
if (sessionStorage.getItem('cartCleared') === '1') {
|
||||
sessionStorage.removeItem('cartCleared');
|
||||
showSuccessToast('Cart cleared successfully');
|
||||
}
|
||||
} catch (_) { /* storage may be blocked */ }
|
||||
});
|
||||
|
||||
// Helper: zero Order Summary values and disable checkout when empty
|
||||
function setMarketplaceSummaryEmpty() {
|
||||
try {
|
||||
const summaryCardBody = document.querySelector('.col-lg-4 .card .card-body');
|
||||
if (!summaryCardBody) return;
|
||||
// Update Subtotal and Total values to $0.00
|
||||
summaryCardBody.querySelectorAll('.d-flex.justify-content-between').forEach(row => {
|
||||
const spans = row.querySelectorAll('span');
|
||||
if (spans.length >= 2) {
|
||||
const label = spans[0].textContent.trim();
|
||||
if (label.startsWith('Subtotal')) spans[1].textContent = '$0.00';
|
||||
if (label === 'Total') spans[1].textContent = '$0.00';
|
||||
}
|
||||
});
|
||||
// Disable checkout if present
|
||||
const checkoutBtn = summaryCardBody.querySelector('.btn.btn-primary.btn-lg');
|
||||
if (checkoutBtn) {
|
||||
if (checkoutBtn.tagName === 'BUTTON') {
|
||||
checkoutBtn.disabled = true;
|
||||
} else {
|
||||
checkoutBtn.classList.add('disabled');
|
||||
checkoutBtn.setAttribute('aria-disabled', 'true');
|
||||
checkoutBtn.setAttribute('tabindex', '-1');
|
||||
}
|
||||
}
|
||||
} catch (_) { /* noop */ }
|
||||
}
|
||||
|
||||
// Update item quantity using apiJson
|
||||
async function updateQuantity(productId, newQuantity) {
|
||||
if (newQuantity < 1) {
|
||||
removeFromCart(productId);
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
const data = await window.apiJson(`/api/cart/item/${productId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
quantity: newQuantity
|
||||
})
|
||||
});
|
||||
|
||||
// Success - reload page to show updated cart
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
handleApiError(error, 'updating quantity');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Show clear cart modal
|
||||
function showClearCartModal() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('clearCartModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Show remove item modal
|
||||
function showRemoveItemModal(productId) {
|
||||
productIdToRemove = productId;
|
||||
const modal = new bootstrap.Modal(document.getElementById('removeItemModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
// Confirm remove item (called from modal)
|
||||
async function confirmRemoveItem() {
|
||||
if (!productIdToRemove) return;
|
||||
|
||||
// Hide the modal first
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('removeItemModal'));
|
||||
modal.hide();
|
||||
|
||||
await removeFromCart(productIdToRemove);
|
||||
productIdToRemove = null;
|
||||
}
|
||||
|
||||
// Remove item from cart using apiJson
|
||||
async function removeFromCart(productId) {
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
await window.apiJson(`/api/cart/item/${productId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
showSuccessToast('Item removed from cart');
|
||||
|
||||
// Remove the item from DOM immediately
|
||||
const cartItem = document.querySelector(`[data-product-id="${productId}"]`);
|
||||
if (cartItem) {
|
||||
cartItem.remove();
|
||||
}
|
||||
|
||||
// Notify globally and update navbar cart count
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
|
||||
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
|
||||
|
||||
// Update cart counts and check if cart is empty
|
||||
await refreshCartContents();
|
||||
} catch (error) {
|
||||
handleApiError(error, 'removing item from cart');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear entire cart using apiJson
|
||||
async function clearCart() {
|
||||
// Hide the modal first
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('clearCartModal'));
|
||||
modal.hide();
|
||||
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart', { method: 'DELETE' });
|
||||
|
||||
// Emit and update counts, then reload to ensure consistent empty state
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(0); }
|
||||
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
|
||||
try { sessionStorage.setItem('cartCleared', '1'); } catch (_) { /* storage may be blocked */ }
|
||||
setTimeout(() => { window.location.reload(); }, 50);
|
||||
} catch (error) {
|
||||
handleApiError(error, 'clearing cart');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Change display currency using apiJson
|
||||
async function changeCurrency() {
|
||||
const currencySelector = document.getElementById('currencySelector');
|
||||
const selectedCurrency = currencySelector.value;
|
||||
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/user/currency', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
currency: selectedCurrency
|
||||
})
|
||||
});
|
||||
|
||||
// Reload page to show prices in new currency
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
handleApiError(error, 'changing currency');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh cart contents using apiJson
|
||||
async function refreshCartContents() {
|
||||
try {
|
||||
// Fetch fresh cart data from server
|
||||
const data = await window.apiJson('/api/cart', {
|
||||
method: 'GET',
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
// Check if cart is empty and update UI accordingly
|
||||
const cartItems = data.items || [];
|
||||
if (cartItems.length === 0) {
|
||||
// Show empty cart state
|
||||
const cartContainer = document.querySelector('.cart-items-container');
|
||||
if (cartContainer) {
|
||||
cartContainer.innerHTML = `
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-cart-x display-1 text-muted"></i>
|
||||
<h3 class="mt-3">Your cart is empty</h3>
|
||||
<p class="text-muted">Add some items to get started!</p>
|
||||
<a href="/marketplace" class="btn btn-primary">Browse Marketplace</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
setMarketplaceSummaryEmpty();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing cart contents:', error);
|
||||
// Don't show error toast for this background operation
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cart functionality for product pages
|
||||
async function addToCartFromPage(productId, quantity = 1, buttonElement = null) {
|
||||
if (buttonElement) {
|
||||
setButtonLoading(buttonElement, 'Adding...');
|
||||
}
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: quantity
|
||||
})
|
||||
});
|
||||
|
||||
if (buttonElement) {
|
||||
setButtonSuccess(buttonElement, 'Added!');
|
||||
}
|
||||
showSuccessToast('Item added to cart');
|
||||
|
||||
// Update cart count in navbar
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
window.updateCartCount();
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, 'adding to cart', buttonElement);
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function showLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize recommended products functionality
|
||||
function initializeRecommendedProducts() {
|
||||
// Add event listeners for recommended product add-to-cart buttons
|
||||
document.querySelectorAll('.recommended-product .add-to-cart-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const productId = this.dataset.productId;
|
||||
if (productId) {
|
||||
addToCartFromPage(productId, 1, this);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Make functions available globally for backward compatibility
|
||||
window.addToCartFromPage = addToCartFromPage;
|
||||
window.refreshCartContents = refreshCartContents;
|
357
src/static/js/cart.js
Normal file
357
src/static/js/cart.js
Normal file
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
Cart interactions (CSP compliant)
|
||||
- Parses hydration JSON from #cart-hydration
|
||||
- Binds all cart-related events (increase/decrease qty, remove, clear, currency change)
|
||||
- Handles guest checkout modal buttons
|
||||
- Shows toasts via createToast helpers here
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let hydration = {
|
||||
item_count: 0,
|
||||
redirect_login_url: '/login?checkout=true',
|
||||
redirect_register_url: '/register?checkout=true',
|
||||
redirect_after_auth: '/cart'
|
||||
};
|
||||
|
||||
function readHydration() {
|
||||
try {
|
||||
const el = document.getElementById('cart-hydration');
|
||||
if (!el) return;
|
||||
const parsed = JSON.parse(el.textContent || '{}');
|
||||
hydration = Object.assign(hydration, parsed || {});
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse cart hydration JSON', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.classList.remove('d-none');
|
||||
}
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.classList.add('d-none');
|
||||
}
|
||||
|
||||
function createToast(message, type, icon) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
|
||||
toast.style.top = '80px';
|
||||
toast.style.zIndex = '10000';
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi ${icon} me-2"></i>${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
if (window.bootstrap && window.bootstrap.Toast) {
|
||||
const bsToast = new window.bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
||||
}
|
||||
}
|
||||
function showError(message) { createToast(message, 'danger', 'bi-exclamation-triangle'); }
|
||||
function showSuccess(message) { createToast(message, 'success', 'bi-check-circle'); }
|
||||
|
||||
async function parseResponse(response) {
|
||||
let json = {};
|
||||
try { json = await response.json(); } catch (_) {}
|
||||
const payload = (json && (json.data || json)) || {};
|
||||
const success = (typeof json.success === 'boolean') ? json.success : (typeof payload.success === 'boolean' ? payload.success : response.ok);
|
||||
return { json, payload, success };
|
||||
}
|
||||
|
||||
async function updateQuantity(productId, newQuantity) {
|
||||
if (newQuantity < 1) {
|
||||
await removeFromCart(productId);
|
||||
return;
|
||||
}
|
||||
showLoading();
|
||||
try {
|
||||
await window.apiJson(`/api/cart/item/${encodeURIComponent(productId)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ quantity: newQuantity })
|
||||
});
|
||||
{
|
||||
showSuccess('Quantity updated successfully');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : 'Network error occurred');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
let productIdToRemove = null;
|
||||
|
||||
function showClearCartModal() {
|
||||
const el = document.getElementById('clearCartModal');
|
||||
if (!el || !window.bootstrap) return;
|
||||
const modal = new window.bootstrap.Modal(el);
|
||||
modal.show();
|
||||
}
|
||||
function showRemoveItemModal(productId) {
|
||||
productIdToRemove = productId;
|
||||
const el = document.getElementById('removeItemModal');
|
||||
if (!el || !window.bootstrap) return;
|
||||
const modal = new window.bootstrap.Modal(el);
|
||||
modal.show();
|
||||
}
|
||||
async function confirmRemoveItem() {
|
||||
if (!productIdToRemove) return;
|
||||
const el = document.getElementById('removeItemModal');
|
||||
if (el && window.bootstrap) {
|
||||
const modal = window.bootstrap.Modal.getInstance(el);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
await removeFromCart(productIdToRemove);
|
||||
productIdToRemove = null;
|
||||
}
|
||||
|
||||
async function removeFromCart(productId) {
|
||||
showLoading();
|
||||
try {
|
||||
await window.apiJson(`/api/cart/item/${encodeURIComponent(productId)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
{
|
||||
showSuccess('Item removed from cart');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : 'Network error occurred');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCart() {
|
||||
const clearEl = document.getElementById('clearCartModal');
|
||||
if (clearEl && window.bootstrap) {
|
||||
const modal = window.bootstrap.Modal.getInstance(clearEl);
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
showLoading();
|
||||
try {
|
||||
await window.apiJson('/api/cart', { method: 'DELETE' });
|
||||
{
|
||||
showSuccess('Cart cleared successfully');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : 'Network error occurred');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
async function changeCurrency() {
|
||||
const currencySelector = document.getElementById('currencySelector');
|
||||
if (!currencySelector) return;
|
||||
const selectedCurrency = currencySelector.value;
|
||||
showLoading();
|
||||
try {
|
||||
await window.apiJson('/api/user/currency', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ currency: selectedCurrency })
|
||||
});
|
||||
{
|
||||
showSuccess('Currency updated');
|
||||
setTimeout(() => window.location.reload(), 500);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e && e.message ? e.message : 'Network error occurred');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
function saveForLater() {
|
||||
showSuccess('Cart saved for later');
|
||||
}
|
||||
function shareCart() {
|
||||
const shareData = {
|
||||
title: 'My ThreeFold Cart',
|
||||
text: 'Check out my ThreeFold marketplace cart',
|
||||
url: window.location.href
|
||||
};
|
||||
if (navigator.share) {
|
||||
navigator.share(shareData).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
.then(() => showSuccess('Cart link copied to clipboard'))
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh cart contents after small delay (used by recommended add)
|
||||
function refreshCartContents() {
|
||||
setTimeout(() => { window.location.reload(); }, 500);
|
||||
}
|
||||
|
||||
// Initialize recommended products add-to-cart buttons
|
||||
function initializeRecommendedProducts() {
|
||||
document.querySelectorAll('.add-recommended-btn').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const productId = this.getAttribute('data-product-id');
|
||||
const productName = this.getAttribute('data-product-name') || 'Product';
|
||||
const productCategory = this.getAttribute('data-product-category') || '';
|
||||
addRecommendedToCart(productId, productName, productCategory, this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addRecommendedToCart(productId, productName, productCategory, buttonEl) {
|
||||
if (!productId || !buttonEl) return;
|
||||
const original = buttonEl.innerHTML;
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Adding...';
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ product_id: productId, quantity: 1, specifications: {} })
|
||||
});
|
||||
buttonEl.innerHTML = '<i class="bi bi-check me-1"></i>Added!';
|
||||
buttonEl.classList.remove('btn-primary', 'btn-success', 'btn-info', 'btn-warning');
|
||||
buttonEl.classList.add('btn-success');
|
||||
createToast(`${productName} added to cart!`, 'success', 'bi-check-circle');
|
||||
if (typeof window.updateCartCount === 'function') window.updateCartCount();
|
||||
try { if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated(undefined); } catch (_) {}
|
||||
updateMyOrdersVisibility();
|
||||
refreshCartContents();
|
||||
setTimeout(() => {
|
||||
buttonEl.innerHTML = original;
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.classList.remove('btn-success');
|
||||
if (productCategory === 'compute') buttonEl.classList.add('btn-primary');
|
||||
else if (productCategory === 'storage') buttonEl.classList.add('btn-success');
|
||||
else if (productCategory === 'gateways') buttonEl.classList.add('btn-info');
|
||||
else if (productCategory === 'applications') buttonEl.classList.add('btn-warning');
|
||||
else buttonEl.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
console.error('Error adding recommended product:', e);
|
||||
if (e && e.status === 402) {
|
||||
return;
|
||||
}
|
||||
buttonEl.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
buttonEl.classList.add('btn-danger');
|
||||
createToast(`Failed to add ${productName} to cart. Please try again.`, 'danger', 'bi-exclamation-triangle');
|
||||
setTimeout(() => {
|
||||
buttonEl.innerHTML = original;
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.classList.remove('btn-danger');
|
||||
if (productCategory === 'compute') buttonEl.classList.add('btn-primary');
|
||||
else if (productCategory === 'storage') buttonEl.classList.add('btn-success');
|
||||
else if (productCategory === 'gateways') buttonEl.classList.add('btn-info');
|
||||
else if (productCategory === 'applications') buttonEl.classList.add('btn-warning');
|
||||
else buttonEl.classList.add('btn-primary');
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle visibility of My Orders link based on API
|
||||
async function updateMyOrdersVisibility() {
|
||||
const myOrdersLink = document.getElementById('myOrdersLink');
|
||||
if (!myOrdersLink) return;
|
||||
try {
|
||||
const data = await window.apiJson('/api/orders');
|
||||
const hasOrders = !!(data && Array.isArray(data.orders) && data.orders.length > 0);
|
||||
myOrdersLink.style.display = hasOrders ? 'inline-block' : 'none';
|
||||
} catch (e) {
|
||||
myOrdersLink.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
// Quantity controls
|
||||
document.querySelectorAll('[data-action="increase"], [data-action="decrease"]').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const productId = this.getAttribute('data-product-id');
|
||||
const action = this.getAttribute('data-action');
|
||||
const qtySpan = this.parentElement.querySelector('span');
|
||||
const currentQuantity = parseInt(qtySpan && qtySpan.textContent, 10) || 0;
|
||||
if (action === 'increase') updateQuantity(productId, currentQuantity + 1);
|
||||
else if (action === 'decrease') updateQuantity(productId, currentQuantity - 1);
|
||||
});
|
||||
});
|
||||
|
||||
// Remove buttons
|
||||
document.querySelectorAll('[data-action="remove"]').forEach(button => {
|
||||
button.addEventListener('click', function () {
|
||||
const productId = this.getAttribute('data-product-id');
|
||||
showRemoveItemModal(productId);
|
||||
});
|
||||
});
|
||||
|
||||
// Clear cart
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (clearCartBtn) clearCartBtn.addEventListener('click', showClearCartModal);
|
||||
|
||||
const confirmClearCartBtn = document.getElementById('confirmClearCartBtn');
|
||||
if (confirmClearCartBtn) confirmClearCartBtn.addEventListener('click', clearCart);
|
||||
|
||||
const confirmRemoveItemBtn = document.getElementById('confirmRemoveItemBtn');
|
||||
if (confirmRemoveItemBtn) confirmRemoveItemBtn.addEventListener('click', confirmRemoveItem);
|
||||
|
||||
// Currency selector
|
||||
const currencySelector = document.getElementById('currencySelector');
|
||||
if (currencySelector) currencySelector.addEventListener('change', changeCurrency);
|
||||
|
||||
// Extra actions
|
||||
document.querySelectorAll('[data-action="save-for-later"]').forEach(btn => btn.addEventListener('click', saveForLater));
|
||||
document.querySelectorAll('[data-action="share-cart"]').forEach(btn => btn.addEventListener('click', shareCart));
|
||||
|
||||
// Guest checkout modal buttons
|
||||
const loginBtn = document.getElementById('guestLoginBtn');
|
||||
const registerBtn = document.getElementById('guestRegisterBtn');
|
||||
if (loginBtn) {
|
||||
loginBtn.addEventListener('click', function () {
|
||||
try { sessionStorage.setItem('redirectAfterLogin', hydration.redirect_after_auth || '/cart'); } catch (_) {}
|
||||
window.location.href = hydration.redirect_login_url || '/login?checkout=true';
|
||||
});
|
||||
}
|
||||
if (registerBtn) {
|
||||
registerBtn.addEventListener('click', function () {
|
||||
try { sessionStorage.setItem('redirectAfterRegister', hydration.redirect_after_auth || '/cart'); } catch (_) {}
|
||||
window.location.href = hydration.redirect_register_url || '/register?checkout=true';
|
||||
});
|
||||
}
|
||||
|
||||
// Checkout CTA (guest) fallback: open modal if button present
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
if (checkoutBtn) {
|
||||
checkoutBtn.addEventListener('click', function () {
|
||||
const el = document.getElementById('guestCheckoutModal');
|
||||
if (el && window.bootstrap) {
|
||||
const modal = new window.bootstrap.Modal(el);
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
readHydration();
|
||||
bindEvents();
|
||||
initializeRecommendedProducts();
|
||||
updateMyOrdersVisibility();
|
||||
});
|
||||
})();
|
162
src/static/js/checkout.js
Normal file
162
src/static/js/checkout.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/* eslint-disable no-console */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Hydration loader
|
||||
function getHydration() {
|
||||
try {
|
||||
const el = document.getElementById('checkout-hydration');
|
||||
if (!el) return {};
|
||||
const text = el.textContent || el.innerText || '';
|
||||
if (!text.trim()) return {};
|
||||
const parsed = JSON.parse(text);
|
||||
return parsed && parsed.data ? parsed.data : parsed; // tolerate ResponseBuilder-style or raw
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse checkout hydration JSON:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Price formatting helpers (client-side polish only; server already formats)
|
||||
function formatPrice(priceText) {
|
||||
const match = String(priceText).match(/(\d+\.?\d*)\s*([A-Z]{3})/);
|
||||
if (match) {
|
||||
const number = parseFloat(match[1]);
|
||||
const currency = match[2];
|
||||
const formatted = isFinite(number) ? number.toFixed(2) : match[1];
|
||||
return `${formatted} ${currency}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatPriceDisplays() {
|
||||
const subtotalElement = document.getElementById('subtotal-display');
|
||||
if (subtotalElement) {
|
||||
const formatted = formatPrice(subtotalElement.textContent);
|
||||
if (formatted) subtotalElement.textContent = formatted;
|
||||
}
|
||||
|
||||
const totalElement = document.getElementById('total-display');
|
||||
if (totalElement) {
|
||||
const formatted = formatPrice(totalElement.textContent);
|
||||
if (formatted) totalElement.textContent = formatted;
|
||||
}
|
||||
|
||||
const priceElements = document.querySelectorAll('.fw-bold.text-primary');
|
||||
priceElements.forEach((el) => {
|
||||
if (el.id !== 'total-display') {
|
||||
const formatted = formatPrice(el.textContent);
|
||||
if (formatted) el.textContent = formatted;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// UI helpers
|
||||
function showLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.classList.remove('d-none');
|
||||
}
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('loadingOverlay');
|
||||
if (overlay) overlay.classList.add('d-none');
|
||||
}
|
||||
function createToast(message, type, icon) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
|
||||
toast.style.top = '80px';
|
||||
toast.style.zIndex = '10000';
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi ${icon} me-2"></i>${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(toast);
|
||||
try {
|
||||
// Bootstrap 5 toast
|
||||
// eslint-disable-next-line no-undef
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
setTimeout(() => toast.remove(), 4000);
|
||||
}
|
||||
}
|
||||
function showError(message) { createToast(message, 'danger', 'bi-exclamation-triangle'); }
|
||||
function showSuccess(message) { createToast(message, 'success', 'bi-check-circle'); }
|
||||
|
||||
// Core action
|
||||
async function processPayment(userCurrency) {
|
||||
showLoading();
|
||||
try {
|
||||
const body = {
|
||||
payment_method: {
|
||||
method_type: 'wallet',
|
||||
details: { source: 'usd_credits' },
|
||||
},
|
||||
currency: userCurrency || 'USD',
|
||||
cart_items: [], // server constructs order from session cart; keep for forward-compat
|
||||
};
|
||||
|
||||
const data = await window.apiJson('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
showSuccess('Order placed successfully!');
|
||||
if (typeof window.loadNavbarData === 'function') { window.loadNavbarData(); }
|
||||
if (typeof window.refreshOrders === 'function') { window.refreshOrders(); }
|
||||
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
|
||||
|
||||
// Try to clear server-side cart (best-effort)
|
||||
try { await window.apiJson('/api/cart', { method: 'DELETE' }); } catch (_) {}
|
||||
|
||||
const orderId = data && (data.order_id || data.id);
|
||||
const confirmation = data && (data.confirmation_number || data.confirmation);
|
||||
setTimeout(() => {
|
||||
if (orderId) {
|
||||
const url = confirmation
|
||||
? `/orders/${orderId}/confirmation?confirmation=${encodeURIComponent(confirmation)}`
|
||||
: `/orders/${orderId}/confirmation`;
|
||||
window.location.href = url;
|
||||
} else {
|
||||
showError('Order created but no order ID returned by server.');
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Let global interceptor handle insufficient funds UI
|
||||
return;
|
||||
}
|
||||
showError(e && e.message ? e.message : 'Network error occurred. Please try again.');
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for debugging if needed
|
||||
window.checkoutProcessPayment = processPayment;
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const hydration = getHydration();
|
||||
const userCurrency = hydration && hydration.user_currency ? hydration.user_currency : 'USD';
|
||||
|
||||
formatPriceDisplays();
|
||||
|
||||
const btn = document.getElementById('complete-order-btn');
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
processPayment(userCurrency);
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
1387
src/static/js/dashboard-app-provider.js
Normal file
1387
src/static/js/dashboard-app-provider.js
Normal file
File diff suppressed because it is too large
Load Diff
8032
src/static/js/dashboard-farmer.js
Normal file
8032
src/static/js/dashboard-farmer.js
Normal file
File diff suppressed because it is too large
Load Diff
678
src/static/js/dashboard-messages.js
Normal file
678
src/static/js/dashboard-messages.js
Normal file
@@ -0,0 +1,678 @@
|
||||
/**
|
||||
* Dashboard Messages Page - Full-page messaging interface
|
||||
* Follows Project Mycelium design patterns and CSP compliance
|
||||
*/
|
||||
|
||||
class DashboardMessaging {
|
||||
constructor() {
|
||||
this.currentThread = null;
|
||||
this.threads = [];
|
||||
this.unreadCount = 0;
|
||||
this.userEmail = null;
|
||||
this.pollInterval = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Get user email from hydration data
|
||||
this.getUserEmail();
|
||||
|
||||
// Set up event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Load conversations
|
||||
await this.loadConversations();
|
||||
|
||||
// Check for URL parameters to auto-open conversation
|
||||
await this.handleUrlParameters();
|
||||
|
||||
// Start polling for updates
|
||||
this.startPolling();
|
||||
|
||||
this.isInitialized = true;
|
||||
console.log('📨 Dashboard messaging initialized for user:', this.userEmail);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize dashboard messaging:', error);
|
||||
this.showError('Failed to initialize messaging system');
|
||||
}
|
||||
}
|
||||
|
||||
getUserEmail() {
|
||||
// Get from hydration data
|
||||
const hydrationData = document.getElementById('messages-hydration');
|
||||
if (hydrationData) {
|
||||
try {
|
||||
const data = JSON.parse(hydrationData.textContent);
|
||||
if (data.user_email) {
|
||||
this.userEmail = data.user_email;
|
||||
window.currentUserEmail = data.user_email;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing messages hydration data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to global messaging system detection
|
||||
if (window.messagingSystem && window.messagingSystem.getCurrentUserEmail) {
|
||||
this.userEmail = window.messagingSystem.getCurrentUserEmail();
|
||||
}
|
||||
|
||||
if (!this.userEmail) {
|
||||
console.warn('Could not determine user email for dashboard messaging');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Refresh button
|
||||
document.getElementById('refreshMessagesBtn')?.addEventListener('click', () => {
|
||||
this.loadConversations();
|
||||
});
|
||||
|
||||
// Send message button
|
||||
document.getElementById('sendMessageBtn')?.addEventListener('click', () => {
|
||||
this.sendMessage();
|
||||
});
|
||||
|
||||
// Message input - Enter key
|
||||
document.getElementById('messageInput')?.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Character count
|
||||
document.getElementById('messageInput')?.addEventListener('input', (e) => {
|
||||
const count = e.target.value.length;
|
||||
document.getElementById('messageCharCount').textContent = count;
|
||||
|
||||
// Update button state
|
||||
const sendBtn = document.getElementById('sendMessageBtn');
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = count === 0 || count > 1000;
|
||||
}
|
||||
});
|
||||
|
||||
// Mark as read button
|
||||
document.getElementById('markAsReadBtn')?.addEventListener('click', () => {
|
||||
if (this.currentThread) {
|
||||
this.markThreadAsRead(this.currentThread.thread_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadConversations() {
|
||||
const loadingEl = document.getElementById('conversationsLoading');
|
||||
const emptyEl = document.getElementById('conversationsEmpty');
|
||||
const listEl = document.getElementById('conversationsList');
|
||||
|
||||
// Show loading state
|
||||
loadingEl?.classList.remove('d-none');
|
||||
emptyEl?.classList.add('d-none');
|
||||
|
||||
try {
|
||||
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
|
||||
this.threads = data.threads || [];
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
|
||||
console.log('📨 Loaded conversations:', this.threads.length, 'unread:', this.unreadCount);
|
||||
|
||||
// Update UI
|
||||
this.renderConversationsList();
|
||||
this.updateUnreadBadges();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
this.showError('Failed to load conversations');
|
||||
} finally {
|
||||
loadingEl?.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
renderConversationsList() {
|
||||
const listEl = document.getElementById('conversationsList');
|
||||
const emptyEl = document.getElementById('conversationsEmpty');
|
||||
const countEl = document.getElementById('totalConversationsCount');
|
||||
|
||||
if (!listEl) return;
|
||||
|
||||
// Update count
|
||||
if (countEl) {
|
||||
countEl.textContent = this.threads.length;
|
||||
}
|
||||
|
||||
if (this.threads.length === 0) {
|
||||
listEl.innerHTML = '';
|
||||
emptyEl?.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyEl?.classList.add('d-none');
|
||||
|
||||
// Render conversations
|
||||
listEl.innerHTML = this.threads.map(thread => {
|
||||
const isUnread = thread.unread_count > 0;
|
||||
const lastMessage = thread.last_message || {};
|
||||
const timeAgo = this.formatTimeAgo(lastMessage.timestamp);
|
||||
|
||||
return `
|
||||
<div class="list-group-item list-group-item-action ${isUnread ? 'border-start border-primary border-3' : ''}"
|
||||
data-thread-id="${thread.thread_id}"
|
||||
style="cursor: pointer;">
|
||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h6 class="mb-0 ${isUnread ? 'fw-bold' : ''}">${this.escapeHtml(thread.subject || 'Conversation')}</h6>
|
||||
${isUnread ? `<span class="badge bg-danger rounded-pill">${thread.unread_count}</span>` : ''}
|
||||
</div>
|
||||
<p class="mb-1 text-muted small">
|
||||
<i class="bi bi-person me-1"></i>${this.escapeHtml(thread.recipient_email)}
|
||||
</p>
|
||||
${lastMessage.content ? `
|
||||
<p class="mb-1 small text-truncate" style="max-width: 200px;">
|
||||
${this.escapeHtml(lastMessage.content)}
|
||||
</p>
|
||||
` : ''}
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-tag me-1"></i>${thread.context_type.replace('_', ' ')}
|
||||
</small>
|
||||
${timeAgo ? `<small class="text-muted">${timeAgo}</small>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Add click handlers
|
||||
listEl.querySelectorAll('[data-thread-id]').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const threadId = item.dataset.threadId;
|
||||
this.selectConversation(threadId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async selectConversation(threadId) {
|
||||
const thread = this.threads.find(t => t.thread_id === threadId);
|
||||
if (!thread) return;
|
||||
|
||||
this.currentThread = thread;
|
||||
|
||||
// Update UI state
|
||||
this.updateConversationHeader();
|
||||
this.showMessageInterface();
|
||||
|
||||
// Load messages
|
||||
await this.loadMessages(threadId);
|
||||
|
||||
// Mark as read if it has unread messages
|
||||
if (thread.unread_count > 0) {
|
||||
await this.markThreadAsRead(threadId);
|
||||
}
|
||||
|
||||
// Update active state in list
|
||||
document.querySelectorAll('#conversationsList .list-group-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
document.querySelector(`[data-thread-id="${threadId}"]`)?.classList.add('active');
|
||||
}
|
||||
|
||||
updateConversationHeader() {
|
||||
const headerEl = document.getElementById('conversationHeader');
|
||||
const titleEl = document.getElementById('conversationTitle');
|
||||
const participantEl = document.getElementById('conversationParticipant');
|
||||
const contextEl = document.getElementById('conversationContext');
|
||||
|
||||
if (!this.currentThread || !headerEl) return;
|
||||
|
||||
headerEl.classList.remove('d-none');
|
||||
|
||||
if (titleEl) {
|
||||
titleEl.textContent = this.currentThread.subject || 'Conversation';
|
||||
}
|
||||
|
||||
if (participantEl) {
|
||||
participantEl.textContent = `with ${this.currentThread.recipient_email}`;
|
||||
}
|
||||
|
||||
if (contextEl) {
|
||||
contextEl.textContent = this.currentThread.context_type.replace('_', ' ');
|
||||
contextEl.className = `badge bg-${this.getContextColor(this.currentThread.context_type)}`;
|
||||
}
|
||||
}
|
||||
|
||||
showMessageInterface() {
|
||||
// Hide welcome, show message interface
|
||||
document.getElementById('messagesWelcome')?.classList.add('d-none');
|
||||
document.getElementById('messagesContainer')?.classList.remove('d-none');
|
||||
document.getElementById('messageInputContainer')?.classList.remove('d-none');
|
||||
|
||||
// Enable input
|
||||
const input = document.getElementById('messageInput');
|
||||
const sendBtn = document.getElementById('sendMessageBtn');
|
||||
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
if (sendBtn) {
|
||||
sendBtn.disabled = input?.value.trim().length === 0;
|
||||
}
|
||||
}
|
||||
|
||||
async loadMessages(threadId) {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
if (!container) return;
|
||||
|
||||
try {
|
||||
const data = await window.apiJson(`/api/messages/threads/${threadId}/messages`, { cache: 'no-store' });
|
||||
const messages = data.messages || [];
|
||||
|
||||
this.renderMessages(messages);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading messages:', error);
|
||||
this.showError('Failed to load messages');
|
||||
}
|
||||
}
|
||||
|
||||
renderMessages(messages) {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (messages.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-chat-dots fs-1 mb-3"></i>
|
||||
<p>No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = messages.map(message => {
|
||||
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
|
||||
const messageTime = new Date(message.timestamp).toLocaleString();
|
||||
const senderName = isOwnMessage ? 'You' : message.sender_email;
|
||||
|
||||
return `
|
||||
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
|
||||
<div class="message-wrapper" style="max-width: 70%;">
|
||||
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${this.escapeHtml(senderName)}</div>` : ''}
|
||||
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm"
|
||||
style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
|
||||
<div class="message-content">${this.escapeHtml(message.content)}</div>
|
||||
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
|
||||
</div>
|
||||
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${this.escapeHtml(senderName)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const input = document.getElementById('messageInput');
|
||||
const sendBtn = document.getElementById('sendMessageBtn');
|
||||
|
||||
if (!input || !this.currentThread) return;
|
||||
|
||||
const content = input.value.trim();
|
||||
if (!content) return;
|
||||
|
||||
// Disable input during send
|
||||
input.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
||||
|
||||
try {
|
||||
const messageData = {
|
||||
thread_id: this.currentThread.thread_id,
|
||||
content: content,
|
||||
message_type: 'text'
|
||||
};
|
||||
|
||||
const response = await window.apiJson('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(messageData)
|
||||
});
|
||||
|
||||
// Add message to UI immediately
|
||||
this.addMessageToUI(response.message);
|
||||
|
||||
// Clear input
|
||||
input.value = '';
|
||||
document.getElementById('messageCharCount').textContent = '0';
|
||||
|
||||
// Refresh conversations list to update last message
|
||||
await this.loadConversations();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
this.showError('Failed to send message');
|
||||
} finally {
|
||||
// Re-enable input
|
||||
input.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.innerHTML = '<i class="bi bi-send"></i>';
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
addMessageToUI(message) {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
if (!container) return;
|
||||
|
||||
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
|
||||
const messageTime = new Date(message.timestamp).toLocaleString();
|
||||
const senderName = isOwnMessage ? 'You' : message.sender_email;
|
||||
|
||||
const messageHTML = `
|
||||
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
|
||||
<div class="message-wrapper" style="max-width: 70%;">
|
||||
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${this.escapeHtml(senderName)}</div>` : ''}
|
||||
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm"
|
||||
style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
|
||||
<div class="message-content">${this.escapeHtml(message.content)}</div>
|
||||
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
|
||||
</div>
|
||||
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${this.escapeHtml(senderName)}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', messageHTML);
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
async markThreadAsRead(threadId) {
|
||||
try {
|
||||
await window.apiJson(`/api/messages/threads/${threadId}/read`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
const thread = this.threads.find(t => t.thread_id === threadId);
|
||||
if (thread && thread.unread_count > 0) {
|
||||
const readCount = thread.unread_count;
|
||||
this.unreadCount -= readCount;
|
||||
thread.unread_count = 0;
|
||||
|
||||
// Update UI
|
||||
this.updateUnreadBadges();
|
||||
this.renderConversationsList();
|
||||
|
||||
// Notify global notification system
|
||||
if (window.notificationSystem) {
|
||||
window.notificationSystem.markAsRead(readCount);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
document.dispatchEvent(new CustomEvent('messageRead', {
|
||||
detail: { threadId, count: readCount }
|
||||
}));
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error marking thread as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
startPolling() {
|
||||
if (this.pollInterval) return;
|
||||
|
||||
this.pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const previousUnreadCount = this.unreadCount;
|
||||
await this.loadConversations();
|
||||
|
||||
// Check if we received new messages
|
||||
if (this.unreadCount > previousUnreadCount) {
|
||||
// Dispatch event for notification system
|
||||
document.dispatchEvent(new CustomEvent('messageReceived', {
|
||||
detail: {
|
||||
count: this.unreadCount - previousUnreadCount,
|
||||
senderEmail: 'another user'
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// If we have a current thread open, refresh its messages
|
||||
if (this.currentThread) {
|
||||
await this.loadMessages(this.currentThread.thread_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling for messages:', error);
|
||||
}
|
||||
}, 15000); // Poll every 15 seconds
|
||||
}
|
||||
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
updateUnreadBadges() {
|
||||
// Update global notification badges
|
||||
const badges = document.querySelectorAll('.message-badge, .unread-messages-badge, .navbar-message-badge');
|
||||
badges.forEach(badge => {
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const now = new Date();
|
||||
const messageTime = new Date(timestamp);
|
||||
const diffMs = now - messageTime;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return messageTime.toLocaleDateString();
|
||||
}
|
||||
|
||||
getContextColor(contextType) {
|
||||
const colors = {
|
||||
'service_booking': 'primary',
|
||||
'slice_rental': 'success',
|
||||
'app_deployment': 'info',
|
||||
'general': 'secondary',
|
||||
'support': 'warning'
|
||||
};
|
||||
return colors[contextType] || 'secondary';
|
||||
}
|
||||
|
||||
async handleUrlParameters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const recipient = urlParams.get('recipient');
|
||||
const context = urlParams.get('context');
|
||||
const subject = urlParams.get('subject');
|
||||
const bookingId = urlParams.get('booking_id');
|
||||
|
||||
if (recipient) {
|
||||
console.log('📨 Auto-opening conversation with:', recipient);
|
||||
console.log('📨 Available threads:', this.threads.map(t => ({
|
||||
id: t.thread_id,
|
||||
recipient: t.recipient_email,
|
||||
subject: t.subject,
|
||||
context_id: t.context_id,
|
||||
context_type: t.context_type
|
||||
})));
|
||||
|
||||
// Find existing thread with this recipient AND booking ID (if provided)
|
||||
let existingThread;
|
||||
if (bookingId) {
|
||||
console.log('📨 Looking for thread with booking ID:', bookingId);
|
||||
console.log('📨 Searching threads for recipient:', recipient, 'and context_id:', bookingId);
|
||||
|
||||
// For service bookings, look for thread with matching booking ID
|
||||
existingThread = this.threads.find(thread => {
|
||||
const matches = thread.recipient_email === recipient && thread.context_id === bookingId;
|
||||
console.log('📨 Thread check:', {
|
||||
thread_id: thread.thread_id,
|
||||
recipient_match: thread.recipient_email === recipient,
|
||||
context_id_match: thread.context_id === bookingId,
|
||||
thread_context_id: thread.context_id,
|
||||
target_booking_id: bookingId,
|
||||
overall_match: matches
|
||||
});
|
||||
return matches;
|
||||
});
|
||||
} else {
|
||||
// For general messages, find any thread with recipient
|
||||
existingThread = this.threads.find(thread =>
|
||||
thread.recipient_email === recipient
|
||||
);
|
||||
}
|
||||
|
||||
console.log('📨 Found existing thread:', existingThread);
|
||||
|
||||
if (existingThread) {
|
||||
// Open existing conversation
|
||||
console.log('📨 Opening existing conversation:', existingThread.thread_id);
|
||||
await this.selectConversation(existingThread.thread_id);
|
||||
} else {
|
||||
// Start new conversation
|
||||
console.log('📨 Starting new conversation with:', recipient, 'for booking:', bookingId);
|
||||
await this.startNewConversation(recipient, context, subject, bookingId);
|
||||
}
|
||||
|
||||
// Clean up URL parameters
|
||||
window.history.replaceState({}, document.title, '/dashboard/messages');
|
||||
}
|
||||
}
|
||||
|
||||
async startNewConversation(recipient, context = 'general', subject = '', bookingId = null) {
|
||||
try {
|
||||
// Create a new thread first
|
||||
const requestData = {
|
||||
recipient_email: recipient,
|
||||
context_type: context,
|
||||
context_id: bookingId,
|
||||
subject: subject || `Service Booking #${bookingId || 'General'}`
|
||||
};
|
||||
|
||||
console.log('📨 Creating new thread with data:', requestData);
|
||||
|
||||
const response = await apiJson('/api/messages/threads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Reload conversations to show the new thread
|
||||
await this.loadConversations();
|
||||
|
||||
// Select the new thread by matching both recipient and context
|
||||
const newThread = this.threads.find(thread =>
|
||||
thread.recipient_email === recipient &&
|
||||
thread.context_id === bookingId
|
||||
);
|
||||
|
||||
if (newThread) {
|
||||
await this.selectConversation(newThread.thread_id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting new conversation:', error);
|
||||
this.showError('Failed to start conversation with provider');
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
const toast = document.getElementById('successToast');
|
||||
const body = document.getElementById('successToastBody');
|
||||
if (toast && body) {
|
||||
body.textContent = message;
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const toast = document.getElementById('errorToast');
|
||||
const body = document.getElementById('errorToastBody');
|
||||
if (toast && body) {
|
||||
body.textContent = message;
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
}
|
||||
}
|
||||
|
||||
formatTimeAgo(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
|
||||
const now = new Date();
|
||||
const messageTime = new Date(timestamp);
|
||||
const diffMs = now - messageTime;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return messageTime.toLocaleDateString();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.stopPolling();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard messaging when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.dashboardMessaging = new DashboardMessaging();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.dashboardMessaging) {
|
||||
window.dashboardMessaging.destroy();
|
||||
}
|
||||
});
|
3374
src/static/js/dashboard-service-provider.js
Normal file
3374
src/static/js/dashboard-service-provider.js
Normal file
File diff suppressed because it is too large
Load Diff
456
src/static/js/dashboard-settings.js
Normal file
456
src/static/js/dashboard-settings.js
Normal file
@@ -0,0 +1,456 @@
|
||||
/**
|
||||
* Dashboard settings page functionality
|
||||
* Handles currency preferences, profile updates, password changes, notifications, and account deletion
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Load notification preferences on page load
|
||||
loadNotificationPreferences();
|
||||
|
||||
// Currency preference form
|
||||
const currencyForm = document.getElementById('currencyForm');
|
||||
if (currencyForm) {
|
||||
currencyForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const currency = formData.get('display_currency');
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
|
||||
try {
|
||||
window.setButtonLoading(submitBtn, 'Updating...');
|
||||
|
||||
await window.apiJson('/api/user/currency', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ currency: currency })
|
||||
});
|
||||
|
||||
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
|
||||
showNotification('Currency preference updated successfully', 'success');
|
||||
|
||||
// Reload page to reflect currency changes
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating currency:', error);
|
||||
showNotification('Error updating currency preference: ' + error.message, 'error');
|
||||
window.resetButton(submitBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Profile update form
|
||||
const profileForm = document.getElementById('profileForm');
|
||||
if (profileForm) {
|
||||
profileForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
|
||||
try {
|
||||
window.setButtonLoading(submitBtn, 'Updating...');
|
||||
|
||||
const result = await window.apiJson('/api/dashboard/settings/profile', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams(formData)
|
||||
});
|
||||
|
||||
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
|
||||
showNotification(result.message || 'Profile updated successfully', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
showNotification('Error updating profile: ' + error.message, 'error');
|
||||
window.resetButton(submitBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Password change form
|
||||
const passwordForm = document.getElementById('passwordForm');
|
||||
if (passwordForm) {
|
||||
passwordForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
|
||||
try {
|
||||
window.setButtonLoading(submitBtn, 'Updating...');
|
||||
|
||||
const result = await window.apiJson('/api/dashboard/settings/password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams(formData)
|
||||
});
|
||||
|
||||
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
|
||||
showNotification(result.message || 'Password updated successfully', 'success');
|
||||
this.reset(); // Clear form
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating password:', error);
|
||||
showNotification('Error updating password: ' + error.message, 'error');
|
||||
window.resetButton(submitBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Notifications form
|
||||
const notificationsForm = document.getElementById('notificationsForm');
|
||||
if (notificationsForm) {
|
||||
notificationsForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
|
||||
// Convert FormData to object with proper boolean values
|
||||
const data = {
|
||||
email_security_alerts: formData.get('email_security_alerts') === 'on',
|
||||
email_billing_alerts: formData.get('email_billing_alerts') === 'on',
|
||||
email_system_alerts: formData.get('email_system_alerts') === 'on',
|
||||
email_newsletter: formData.get('email_newsletter') === 'on',
|
||||
dashboard_alerts: formData.get('dashboard_alerts') === 'on',
|
||||
dashboard_updates: formData.get('dashboard_updates') === 'on'
|
||||
};
|
||||
|
||||
try {
|
||||
window.setButtonLoading(submitBtn, 'Updating...');
|
||||
|
||||
const result = await window.apiJson('/api/dashboard/settings/notifications', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams(data)
|
||||
});
|
||||
|
||||
window.setButtonSuccess(submitBtn, 'Updated!', 2000);
|
||||
showNotification(result.message || 'Notification preferences updated successfully', 'success');
|
||||
saveNotificationPreferences(data);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating notifications:', error);
|
||||
showNotification('Error updating notification preferences: ' + error.message, 'error');
|
||||
window.resetButton(submitBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Account deletion functionality
|
||||
const deleteAccountForm = document.getElementById('deleteAccountForm');
|
||||
if (deleteAccountForm) {
|
||||
deleteAccountForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const confirmationInput = document.getElementById('deleteConfirmation');
|
||||
const confirmation = confirmationInput.value;
|
||||
const passwordInput = document.getElementById('deletePassword');
|
||||
const passwordFeedback = document.getElementById('deletePasswordFeedback');
|
||||
const password = passwordInput.value;
|
||||
|
||||
// reset previous error state
|
||||
passwordInput.classList.remove('is-invalid');
|
||||
|
||||
// Validate checkbox and confirmation text
|
||||
const checkbox = document.getElementById('deleteCheck');
|
||||
if (!checkbox.checked) {
|
||||
showNotification('Please confirm that you understand this action cannot be undone', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirmation !== 'DELETE') {
|
||||
showNotification('Please type DELETE exactly to confirm', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure password is provided
|
||||
if (!password || password.trim() === '') {
|
||||
passwordInput.classList.add('is-invalid');
|
||||
if (passwordFeedback) passwordFeedback.textContent = 'Enter your current password.';
|
||||
passwordInput.focus();
|
||||
showNotification('Enter your current password.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-verify password with backend before showing final confirmation
|
||||
try {
|
||||
const verifyResult = await window.apiJson('/api/dashboard/settings/verify-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams({ password })
|
||||
});
|
||||
|
||||
// Password verification successful - apiJson throws on error, so if we get here it's valid
|
||||
|
||||
} catch (err) {
|
||||
// Handle password verification failure
|
||||
const msg = err.message || 'The password you entered is incorrect. Please try again.';
|
||||
passwordInput.classList.add('is-invalid');
|
||||
if (passwordFeedback) passwordFeedback.textContent = msg;
|
||||
passwordInput.focus();
|
||||
showNotification('Error verifying password: ' + msg, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show custom confirmation modal instead of browser popup
|
||||
if (!await showDeleteConfirmationModal()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
confirmation: confirmation,
|
||||
password: password
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('[DeleteAccount] Payload about to send:', data);
|
||||
|
||||
const result = await window.apiJson('/api/dashboard/settings/delete-account', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: new URLSearchParams(data)
|
||||
});
|
||||
|
||||
console.log('[DeleteAccount] Response JSON:', result);
|
||||
|
||||
// Success
|
||||
showNotification(result.message || 'Account deleted successfully', 'success');
|
||||
|
||||
// Redirect after a brief delay
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting account:', error);
|
||||
|
||||
// Handle password-related errors inline
|
||||
if (error.message && error.message.toLowerCase().includes('password')) {
|
||||
passwordInput.classList.add('is-invalid');
|
||||
if (passwordFeedback) {
|
||||
passwordFeedback.textContent = error.message;
|
||||
}
|
||||
passwordInput.focus();
|
||||
}
|
||||
|
||||
showNotification('Error deleting account: ' + error.message, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Clear inline error when user edits password
|
||||
const pwd = document.getElementById('deletePassword');
|
||||
const pwdFeedback = document.getElementById('deletePasswordFeedback');
|
||||
if (pwd) {
|
||||
pwd.addEventListener('input', () => {
|
||||
pwd.classList.remove('is-invalid');
|
||||
if (pwdFeedback) pwdFeedback.textContent = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Notification function
|
||||
function showNotification(message, type = 'info') {
|
||||
// Remove existing notifications
|
||||
const existingNotifications = document.querySelectorAll('.settings-notification');
|
||||
existingNotifications.forEach(notification => notification.remove());
|
||||
|
||||
// Create new notification
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${getBootstrapAlertClass(type)} alert-dismissible fade show settings-notification`;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function getBootstrapAlertClass(type) {
|
||||
switch (type) {
|
||||
case 'success': return 'success';
|
||||
case 'error': return 'danger';
|
||||
case 'warning': return 'warning';
|
||||
case 'info':
|
||||
default: return 'info';
|
||||
}
|
||||
}
|
||||
|
||||
// Function to load existing notification preferences
|
||||
async function loadNotificationPreferences() {
|
||||
try {
|
||||
// For now, we'll use localStorage to persist preferences across sessions
|
||||
// In a real app, this would come from the server
|
||||
const preferences = {
|
||||
email_security_alerts: localStorage.getItem('email_security_alerts') !== 'false',
|
||||
email_billing_alerts: localStorage.getItem('email_billing_alerts') !== 'false',
|
||||
email_system_alerts: localStorage.getItem('email_system_alerts') !== 'false',
|
||||
email_newsletter: localStorage.getItem('email_newsletter') === 'true',
|
||||
dashboard_alerts: localStorage.getItem('dashboard_alerts') !== 'false',
|
||||
dashboard_updates: localStorage.getItem('dashboard_updates') !== 'false'
|
||||
};
|
||||
|
||||
// Apply preferences to form elements
|
||||
const emailSecurityAlerts = document.getElementById('emailSecurityAlerts');
|
||||
const emailBillingAlerts = document.getElementById('emailBillingAlerts');
|
||||
const emailSystemAlerts = document.getElementById('emailSystemAlerts');
|
||||
const emailNewsletter = document.getElementById('emailNewsletter');
|
||||
const dashboardAlerts = document.getElementById('dashboardAlerts');
|
||||
const dashboardUpdates = document.getElementById('dashboardUpdates');
|
||||
|
||||
if (emailSecurityAlerts) emailSecurityAlerts.checked = preferences.email_security_alerts;
|
||||
if (emailBillingAlerts) emailBillingAlerts.checked = preferences.email_billing_alerts;
|
||||
if (emailSystemAlerts) emailSystemAlerts.checked = preferences.email_system_alerts;
|
||||
if (emailNewsletter) emailNewsletter.checked = preferences.email_newsletter;
|
||||
if (dashboardAlerts) dashboardAlerts.checked = preferences.dashboard_alerts;
|
||||
if (dashboardUpdates) dashboardUpdates.checked = preferences.dashboard_updates;
|
||||
|
||||
console.log('Loaded notification preferences:', preferences);
|
||||
} catch (error) {
|
||||
console.error('Error loading notification preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Function to save notification preferences to localStorage
|
||||
function saveNotificationPreferences(data) {
|
||||
try {
|
||||
localStorage.setItem('email_security_alerts', data.email_security_alerts);
|
||||
localStorage.setItem('email_billing_alerts', data.email_billing_alerts);
|
||||
localStorage.setItem('email_system_alerts', data.email_system_alerts);
|
||||
localStorage.setItem('email_newsletter', data.email_newsletter);
|
||||
localStorage.setItem('dashboard_alerts', data.dashboard_alerts);
|
||||
localStorage.setItem('dashboard_updates', data.dashboard_updates);
|
||||
console.log('Saved notification preferences to localStorage');
|
||||
} catch (error) {
|
||||
console.error('Error saving notification preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom delete confirmation modal functionality
|
||||
function showDeleteConfirmationModal() {
|
||||
return new Promise((resolve) => {
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteConfirmationModal'));
|
||||
const finalConfirmationInput = document.getElementById('finalConfirmationInput');
|
||||
const finalDeleteButton = document.getElementById('finalDeleteButton');
|
||||
const countdownTimer = document.getElementById('countdownTimer');
|
||||
const deleteButtonText = document.getElementById('deleteButtonText');
|
||||
const deleteButtonSpinner = document.getElementById('deleteButtonSpinner');
|
||||
|
||||
let countdownInterval;
|
||||
let countdownActive = false;
|
||||
|
||||
// Reset modal state
|
||||
finalConfirmationInput.value = '';
|
||||
finalDeleteButton.disabled = true;
|
||||
deleteButtonText.textContent = 'Confirm Deletion';
|
||||
deleteButtonSpinner.classList.add('d-none');
|
||||
countdownTimer.textContent = '10';
|
||||
|
||||
// Handle input validation
|
||||
finalConfirmationInput.addEventListener('input', function() {
|
||||
const isValid = this.value.toUpperCase() === 'I UNDERSTAND';
|
||||
finalDeleteButton.disabled = !isValid;
|
||||
|
||||
if (isValid && !countdownActive) {
|
||||
startCountdown();
|
||||
} else if (!isValid && countdownActive) {
|
||||
stopCountdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle final delete button click
|
||||
finalDeleteButton.addEventListener('click', function() {
|
||||
if (finalConfirmationInput.value.toUpperCase() === 'I UNDERSTAND') {
|
||||
// Show loading state
|
||||
deleteButtonText.textContent = 'Deleting Account...';
|
||||
deleteButtonSpinner.classList.remove('d-none');
|
||||
finalDeleteButton.disabled = true;
|
||||
|
||||
modal.hide();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle modal close
|
||||
document.getElementById('deleteConfirmationModal').addEventListener('hidden.bs.modal', function() {
|
||||
stopCountdown();
|
||||
if (deleteButtonText.textContent === 'Deleting Account...') {
|
||||
// Don't resolve if deletion is in progress
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
function startCountdown() {
|
||||
countdownActive = true;
|
||||
let timeLeft = 10;
|
||||
|
||||
countdownInterval = setInterval(() => {
|
||||
timeLeft--;
|
||||
countdownTimer.textContent = timeLeft;
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
stopCountdown();
|
||||
finalDeleteButton.disabled = false;
|
||||
countdownTimer.textContent = '0';
|
||||
countdownTimer.parentElement.innerHTML = '<span class="text-success fw-bold">Ready to proceed</span>';
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
countdownActive = false;
|
||||
countdownTimer.textContent = '10';
|
||||
countdownTimer.parentElement.innerHTML = 'Deletion will proceed in <span id="countdownTimer" class="fw-bold text-danger">10</span> seconds after confirmation';
|
||||
}
|
||||
|
||||
modal.show();
|
||||
});
|
||||
}
|
533
src/static/js/dashboard-ssh-keys.js
Normal file
533
src/static/js/dashboard-ssh-keys.js
Normal file
@@ -0,0 +1,533 @@
|
||||
/* eslint-disable no-console */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Prevent duplicate execution if script is included more than once
|
||||
if (window.__dashboardSSHKeysScriptLoaded) {
|
||||
console.debug('dashboard-ssh-keys.js already loaded; skipping init');
|
||||
return;
|
||||
}
|
||||
window.__dashboardSSHKeysScriptLoaded = true;
|
||||
|
||||
// Safe JSON parsing utility
|
||||
function safeParseJSON(text, fallback) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// Get SSH keys data from hydration
|
||||
function getSSHKeysData() {
|
||||
const el = document.getElementById('ssh-keys-data');
|
||||
if (!el) return [];
|
||||
return safeParseJSON(el.textContent || el.innerText || '[]', []);
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return 'Never';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
} catch (_) {
|
||||
return 'Invalid date';
|
||||
}
|
||||
}
|
||||
|
||||
// Format SSH key type for display
|
||||
function formatKeyType(keyType) {
|
||||
const typeMap = {
|
||||
'ssh-ed25519': 'Ed25519',
|
||||
'ssh-rsa': 'RSA',
|
||||
'ecdsa-sha2-nistp256': 'ECDSA P-256',
|
||||
'ecdsa-sha2-nistp384': 'ECDSA P-384',
|
||||
'ecdsa-sha2-nistp521': 'ECDSA P-521'
|
||||
};
|
||||
return typeMap[keyType] || keyType;
|
||||
}
|
||||
|
||||
// Get security level for key type
|
||||
function getSecurityLevel(keyType) {
|
||||
const securityMap = {
|
||||
'ssh-ed25519': 'High',
|
||||
'ecdsa-sha2-nistp256': 'High',
|
||||
'ecdsa-sha2-nistp384': 'Very High',
|
||||
'ecdsa-sha2-nistp521': 'Very High',
|
||||
'ssh-rsa': 'Medium to High'
|
||||
};
|
||||
return securityMap[keyType] || 'Unknown';
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
function showLoading(element, text = 'Loading...') {
|
||||
const spinner = element.querySelector('.spinner-border');
|
||||
const textElement = element.querySelector('[id$="Text"]');
|
||||
|
||||
if (spinner) spinner.classList.remove('d-none');
|
||||
if (textElement) textElement.textContent = text;
|
||||
element.disabled = true;
|
||||
}
|
||||
|
||||
// Hide loading state
|
||||
function hideLoading(element, originalText) {
|
||||
const spinner = element.querySelector('.spinner-border');
|
||||
const textElement = element.querySelector('[id$="Text"]');
|
||||
|
||||
if (spinner) spinner.classList.add('d-none');
|
||||
if (textElement) textElement.textContent = originalText;
|
||||
element.disabled = false;
|
||||
}
|
||||
|
||||
// Show notification
|
||||
function showNotification(message, type = 'success') {
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show position-fixed`;
|
||||
notification.style.cssText = 'top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
notification.innerHTML = `
|
||||
<i class="bi bi-${type === 'success' ? 'check-circle' : 'exclamation-triangle'} me-2"></i>
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Validate SSH key format
|
||||
function validateSSHKey(publicKey) {
|
||||
if (!publicKey || !publicKey.trim()) {
|
||||
return { valid: false, error: 'SSH key cannot be empty' };
|
||||
}
|
||||
|
||||
const trimmedKey = publicKey.trim();
|
||||
const parts = trimmedKey.split(/\s+/);
|
||||
|
||||
if (parts.length < 2) {
|
||||
return { valid: false, error: 'Invalid SSH key format. Expected format: "type base64-key [comment]"' };
|
||||
}
|
||||
|
||||
const keyType = parts[0];
|
||||
const validTypes = ['ssh-ed25519', 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521'];
|
||||
|
||||
if (!validTypes.includes(keyType)) {
|
||||
return { valid: false, error: 'Unsupported key type. Please use Ed25519, ECDSA, or RSA keys.' };
|
||||
}
|
||||
|
||||
// Basic base64 validation
|
||||
const keyData = parts[1];
|
||||
if (!/^[A-Za-z0-9+/]+=*$/.test(keyData)) {
|
||||
return { valid: false, error: 'Invalid key encoding. Please check your key format.' };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
keyType: formatKeyType(keyType),
|
||||
securityLevel: getSecurityLevel(keyType)
|
||||
};
|
||||
}
|
||||
|
||||
// Render SSH keys list
|
||||
function renderSSHKeys(sshKeys) {
|
||||
const container = document.getElementById('sshKeysList');
|
||||
const noKeysMessage = document.getElementById('noSSHKeysMessage');
|
||||
const template = document.getElementById('sshKeyTemplate');
|
||||
|
||||
if (!container || !template) return;
|
||||
|
||||
// Clear existing content except no keys message
|
||||
const existingItems = container.querySelectorAll('.ssh-key-item');
|
||||
existingItems.forEach(item => item.remove());
|
||||
|
||||
if (!sshKeys || sshKeys.length === 0) {
|
||||
noKeysMessage.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
noKeysMessage.classList.add('d-none');
|
||||
|
||||
sshKeys.forEach(sshKey => {
|
||||
const keyElement = template.cloneNode(true);
|
||||
keyElement.classList.remove('d-none');
|
||||
keyElement.id = `ssh-key-${sshKey.id}`;
|
||||
|
||||
// Set data-key-id on the actual ssh-key-item div (not the wrapper)
|
||||
const sshKeyItem = keyElement.querySelector('.ssh-key-item');
|
||||
if (sshKeyItem) {
|
||||
sshKeyItem.dataset.keyId = sshKey.id;
|
||||
console.log('🔧 DEBUG: Set data-key-id on ssh-key-item:', sshKey.id, sshKeyItem);
|
||||
} else {
|
||||
console.error('❌ ERROR: Could not find .ssh-key-item in template!');
|
||||
}
|
||||
|
||||
// Populate key information
|
||||
keyElement.querySelector('.ssh-key-name').textContent = sshKey.name;
|
||||
keyElement.querySelector('.ssh-key-type').textContent = formatKeyType(sshKey.key_type);
|
||||
keyElement.querySelector('.ssh-key-fingerprint').textContent = sshKey.fingerprint;
|
||||
keyElement.querySelector('.ssh-key-created').textContent = `Added: ${formatDate(sshKey.created_at)}`;
|
||||
keyElement.querySelector('.ssh-key-last-used').textContent = `Last used: ${formatDate(sshKey.last_used)}`;
|
||||
|
||||
// Show/hide default badge
|
||||
const defaultBadge = keyElement.querySelector('.ssh-key-default');
|
||||
if (sshKey.is_default) {
|
||||
defaultBadge.classList.remove('d-none');
|
||||
}
|
||||
|
||||
// Update button states
|
||||
const setDefaultBtn = keyElement.querySelector('.set-default-btn');
|
||||
if (sshKey.is_default) {
|
||||
setDefaultBtn.textContent = 'Default';
|
||||
setDefaultBtn.disabled = true;
|
||||
setDefaultBtn.classList.add('btn-success');
|
||||
setDefaultBtn.classList.remove('btn-outline-primary');
|
||||
}
|
||||
|
||||
container.appendChild(keyElement);
|
||||
});
|
||||
|
||||
// Attach event listeners to new elements
|
||||
attachKeyEventListeners();
|
||||
}
|
||||
|
||||
// Load SSH keys from server
|
||||
async function loadSSHKeys() {
|
||||
try {
|
||||
const data = await window.apiJson('/api/dashboard/ssh-keys');
|
||||
renderSSHKeys((data && data.ssh_keys) || []);
|
||||
} catch (error) {
|
||||
console.error('Error loading SSH keys:', error);
|
||||
showNotification('Failed to load SSH keys. Please refresh the page.', 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// Add SSH key
|
||||
async function addSSHKey(formData) {
|
||||
try {
|
||||
await window.apiJson('/api/dashboard/ssh-keys', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: formData.get('name'),
|
||||
public_key: formData.get('public_key'),
|
||||
is_default: formData.get('is_default') === 'on'
|
||||
}
|
||||
});
|
||||
|
||||
showNotification('SSH key added successfully!', 'success');
|
||||
|
||||
// Reload SSH keys
|
||||
await loadSSHKeys();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error adding SSH key:', error);
|
||||
showNotification(error.message || 'Failed to add SSH key', 'danger');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete SSH key
|
||||
async function deleteSSHKey(keyId) {
|
||||
try {
|
||||
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
showNotification('SSH key deleted successfully!', 'success');
|
||||
|
||||
// Reload SSH keys
|
||||
await loadSSHKeys();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting SSH key:', error);
|
||||
showNotification(error.message || 'Failed to delete SSH key', 'danger');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default SSH key
|
||||
async function setDefaultSSHKey(keyId) {
|
||||
try {
|
||||
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}/set-default`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
showNotification('Default SSH key updated successfully!', 'success');
|
||||
|
||||
// Reload SSH keys
|
||||
await loadSSHKeys();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error setting default SSH key:', error);
|
||||
showNotification(error.message || 'Failed to set default SSH key', 'danger');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update SSH key
|
||||
async function updateSSHKey(keyId, name, isDefault) {
|
||||
try {
|
||||
await window.apiJson(`/api/dashboard/ssh-keys/${keyId}`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
name: name,
|
||||
is_default: isDefault
|
||||
}
|
||||
});
|
||||
|
||||
showNotification('SSH key updated successfully!', 'success');
|
||||
|
||||
// Reload SSH keys
|
||||
await loadSSHKeys();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating SSH key:', error);
|
||||
showNotification(error.message || 'Failed to update SSH key', 'danger');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach event listeners to SSH key items
|
||||
function attachKeyEventListeners() {
|
||||
// Set default buttons
|
||||
document.querySelectorAll('.set-default-btn').forEach(btn => {
|
||||
if (!btn.disabled) {
|
||||
btn.addEventListener('click', async function() {
|
||||
const keyItem = this.closest('.ssh-key-item');
|
||||
const keyId = keyItem?.dataset?.keyId;
|
||||
|
||||
// Debug logging
|
||||
console.log('Set Default clicked:', { keyItem, keyId });
|
||||
|
||||
if (!keyId) {
|
||||
console.error('No key ID found for set default operation');
|
||||
showNotification('Error: Could not identify SSH key to set as default', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(this, 'Setting...');
|
||||
const success = await setDefaultSSHKey(keyId);
|
||||
if (!success) {
|
||||
hideLoading(this, 'Set Default');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Edit buttons
|
||||
document.querySelectorAll('.edit-ssh-key-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const keyItem = this.closest('.ssh-key-item');
|
||||
const keyId = keyItem?.dataset?.keyId;
|
||||
const keyName = keyItem?.querySelector('.ssh-key-name')?.textContent;
|
||||
const isDefault = keyItem?.querySelector('.ssh-key-default') && !keyItem.querySelector('.ssh-key-default').classList.contains('d-none');
|
||||
|
||||
// Debug logging
|
||||
console.log('Edit clicked:', { keyItem, keyId, keyName, isDefault });
|
||||
|
||||
if (!keyId) {
|
||||
console.error('No key ID found for edit operation');
|
||||
showNotification('Error: Could not identify SSH key to edit', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate edit modal
|
||||
const modal = document.getElementById('editSSHKeyModal');
|
||||
if (modal) {
|
||||
document.getElementById('editSSHKeyId').value = keyId;
|
||||
document.getElementById('editSSHKeyName').value = keyName || '';
|
||||
document.getElementById('editSetAsDefault').checked = isDefault || false;
|
||||
new bootstrap.Modal(modal).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete buttons
|
||||
document.querySelectorAll('.delete-ssh-key-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const keyItem = this.closest('.ssh-key-item');
|
||||
const keyId = keyItem?.dataset?.keyId;
|
||||
const keyName = keyItem?.querySelector('.ssh-key-name')?.textContent;
|
||||
|
||||
// Debug logging
|
||||
console.log('Delete clicked:', { keyItem, keyId, keyName });
|
||||
|
||||
if (!keyId) {
|
||||
console.error('No key ID found for delete operation');
|
||||
showNotification('Error: Could not identify SSH key to delete', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show delete confirmation modal
|
||||
const modal = document.getElementById('deleteSSHKeyModal');
|
||||
if (modal) {
|
||||
document.getElementById('deleteSSHKeyId').value = keyId;
|
||||
document.getElementById('deleteSSHKeyName').textContent = keyName || 'Unknown Key';
|
||||
new bootstrap.Modal(modal).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize SSH key management
|
||||
function initSSHKeyManagement() {
|
||||
// Add SSH key form
|
||||
const addForm = document.getElementById('addSSHKeyForm');
|
||||
if (addForm) {
|
||||
addForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('addSSHKeySubmit');
|
||||
const formData = new FormData(this);
|
||||
|
||||
// Validate SSH key first
|
||||
const validation = validateSSHKey(formData.get('public_key'));
|
||||
const feedbackEl = document.getElementById('keyValidationFeedback');
|
||||
|
||||
if (!validation.valid) {
|
||||
feedbackEl.classList.remove('d-none');
|
||||
document.getElementById('keyValidationSuccess').classList.add('d-none');
|
||||
document.getElementById('keyValidationError').classList.remove('d-none');
|
||||
document.getElementById('keyValidationErrorText').textContent = validation.error;
|
||||
return;
|
||||
}
|
||||
|
||||
feedbackEl.classList.add('d-none');
|
||||
|
||||
showLoading(submitBtn, 'Adding...');
|
||||
const success = await addSSHKey(formData);
|
||||
|
||||
if (success) {
|
||||
// Close modal and reset form
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('addSSHKeyModal'));
|
||||
if (modal) modal.hide();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
hideLoading(submitBtn, 'Add SSH Key');
|
||||
});
|
||||
}
|
||||
|
||||
// Add SSH key button
|
||||
const addBtn = document.getElementById('addSSHKeyBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', function() {
|
||||
const modal = new bootstrap.Modal(document.getElementById('addSSHKeyModal'));
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
// Edit SSH key form
|
||||
const editForm = document.getElementById('editSSHKeyForm');
|
||||
if (editForm) {
|
||||
editForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('editSSHKeySubmit');
|
||||
const formData = new FormData(this);
|
||||
const keyId = formData.get('keyId');
|
||||
const name = formData.get('name');
|
||||
const isDefault = formData.get('is_default') === 'on';
|
||||
|
||||
// Debug logging
|
||||
console.log('Edit form submit:', { keyId, name, isDefault });
|
||||
|
||||
if (!keyId || keyId.trim() === '') {
|
||||
console.error('Edit form submitted without valid key ID');
|
||||
showNotification('Error: No SSH key ID provided for update', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(submitBtn, 'Updating...');
|
||||
const success = await updateSSHKey(keyId, name, isDefault);
|
||||
|
||||
if (success) {
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('editSSHKeyModal'));
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
|
||||
hideLoading(submitBtn, 'Update SSH Key');
|
||||
});
|
||||
}
|
||||
|
||||
// Delete SSH key form
|
||||
const deleteForm = document.getElementById('deleteSSHKeyForm');
|
||||
if (deleteForm) {
|
||||
deleteForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = document.getElementById('deleteSSHKeySubmit');
|
||||
const keyId = document.getElementById('deleteSSHKeyId').value;
|
||||
|
||||
// Debug logging
|
||||
console.log('Delete form submit:', { keyId });
|
||||
|
||||
if (!keyId || keyId.trim() === '') {
|
||||
console.error('Delete form submitted without valid key ID');
|
||||
showNotification('Error: No SSH key ID provided for deletion', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading(submitBtn, 'Deleting...');
|
||||
const success = await deleteSSHKey(keyId);
|
||||
|
||||
if (success) {
|
||||
// Close modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteSSHKeyModal'));
|
||||
if (modal) modal.hide();
|
||||
}
|
||||
|
||||
hideLoading(submitBtn, 'Delete SSH Key');
|
||||
});
|
||||
}
|
||||
|
||||
// SSH key validation on input
|
||||
const publicKeyInput = document.getElementById('sshPublicKey');
|
||||
if (publicKeyInput) {
|
||||
publicKeyInput.addEventListener('input', function() {
|
||||
const validation = validateSSHKey(this.value);
|
||||
const feedbackEl = document.getElementById('keyValidationFeedback');
|
||||
|
||||
if (this.value.trim() === '') {
|
||||
feedbackEl.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
feedbackEl.classList.remove('d-none');
|
||||
|
||||
if (validation.valid) {
|
||||
document.getElementById('keyValidationSuccess').classList.remove('d-none');
|
||||
document.getElementById('keyValidationError').classList.add('d-none');
|
||||
document.getElementById('keyValidationSuccessText').textContent =
|
||||
`Valid ${validation.keyType} key detected! Security level: ${validation.securityLevel}`;
|
||||
} else {
|
||||
document.getElementById('keyValidationSuccess').classList.add('d-none');
|
||||
document.getElementById('keyValidationError').classList.remove('d-none');
|
||||
document.getElementById('keyValidationErrorText').textContent = validation.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load SSH keys on page load
|
||||
loadSSHKeys();
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSSHKeyManagement);
|
||||
} else {
|
||||
initSSHKeyManagement();
|
||||
}
|
||||
|
||||
})();
|
1270
src/static/js/dashboard-user.js
Normal file
1270
src/static/js/dashboard-user.js
Normal file
File diff suppressed because it is too large
Load Diff
189
src/static/js/dashboard.js
Normal file
189
src/static/js/dashboard.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/* eslint-disable no-console */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Prevent duplicate execution if script is included more than once
|
||||
if (window.__dashboardMainScriptLoaded) {
|
||||
console.debug('dashboard.js already loaded; skipping init');
|
||||
return;
|
||||
}
|
||||
window.__dashboardMainScriptLoaded = true;
|
||||
|
||||
function safeParseJSON(text, fallback) {
|
||||
try { return JSON.parse(text); } catch (_) { return fallback; }
|
||||
}
|
||||
|
||||
function getHydratedData() {
|
||||
const el = document.getElementById('dashboard-chart-data');
|
||||
if (!el) return null;
|
||||
return safeParseJSON(el.textContent || el.innerText || '{}', null);
|
||||
}
|
||||
|
||||
function defaultChartData(displayCurrency) {
|
||||
return {
|
||||
displayCurrency: displayCurrency || 'USD',
|
||||
resourceUtilization: { cpu: 0, memory: 0, storage: 0, network: 0 },
|
||||
creditsUsageTrend: [0, 0, 0, 0, 0, 0],
|
||||
userActivity: { deployments: [0, 0, 0, 0, 0, 0], resourceReservations: [0, 0, 0, 0, 0, 0] },
|
||||
deploymentDistribution: {
|
||||
regions: [], nodes: [], slices: [], apps: [], gateways: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function updateDashboardWalletBalance() {
|
||||
try {
|
||||
const data = await window.apiJson('/api/navbar/dropdown-data', { cache: 'no-store' }) || {};
|
||||
|
||||
const balanceEl = document.getElementById('dashboardWalletBalance');
|
||||
if (balanceEl && data && data.wallet_balance_formatted) {
|
||||
balanceEl.textContent = data.wallet_balance_formatted;
|
||||
}
|
||||
const codeEl = document.getElementById('dashboardCurrencyCode');
|
||||
if (codeEl && data && data.display_currency) {
|
||||
codeEl.textContent = data.display_currency;
|
||||
}
|
||||
} catch (_) {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
function initCharts() {
|
||||
if (typeof Chart === 'undefined') return; // Chart.js not loaded
|
||||
|
||||
// Global defaults
|
||||
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
|
||||
Chart.defaults.font.size = 12;
|
||||
Chart.defaults.responsive = true;
|
||||
Chart.defaults.maintainAspectRatio = false;
|
||||
|
||||
const hydrated = getHydratedData();
|
||||
const data = hydrated || defaultChartData('USD');
|
||||
|
||||
// Resource Utilization Overview Chart
|
||||
const resCtxEl = document.getElementById('resourceUtilizationOverviewChart');
|
||||
if (resCtxEl) {
|
||||
const ctx = resCtxEl.getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['CPU', 'Memory', 'Storage', 'Network'],
|
||||
datasets: [{
|
||||
label: 'Current Usage (%)',
|
||||
data: [
|
||||
data.resourceUtilization.cpu,
|
||||
data.resourceUtilization.memory,
|
||||
data.resourceUtilization.storage,
|
||||
data.resourceUtilization.network
|
||||
],
|
||||
backgroundColor: [
|
||||
'rgba(0, 123, 255, 0.7)',
|
||||
'rgba(40, 167, 69, 0.7)',
|
||||
'rgba(255, 193, 7, 0.7)',
|
||||
'rgba(23, 162, 184, 0.7)'
|
||||
],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Resource Utilization Overview' } },
|
||||
scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: 'Utilization %' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Credits Usage Overview Chart
|
||||
const creditsCtxEl = document.getElementById('creditsUsageOverviewChart');
|
||||
if (creditsCtxEl) {
|
||||
const ctx = creditsCtxEl.getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
datasets: [{
|
||||
label: 'Monthly Credits Usage',
|
||||
data: data.creditsUsageTrend,
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Credits Monthly Usage Trend' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: `Credits (${data.displayCurrency || 'USD'})` } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// User Activity Chart
|
||||
const userActivityEl = document.getElementById('userActivityChart');
|
||||
if (userActivityEl) {
|
||||
const ctx = userActivityEl.getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4', 'Week 5', 'Week 6'],
|
||||
datasets: [{
|
||||
label: 'Deployments',
|
||||
data: data.userActivity.deployments,
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.0)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
}, {
|
||||
label: 'Resource Reservations',
|
||||
data: data.userActivity.resourceReservations,
|
||||
borderColor: '#ffc107',
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.0)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'top' }, title: { display: true, text: 'User Activity' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Count' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Deployment Distribution Chart
|
||||
const distEl = document.getElementById('deploymentDistributionChart');
|
||||
if (distEl) {
|
||||
const ctx = distEl.getContext('2d');
|
||||
const dd = data.deploymentDistribution || { regions: [], nodes: [], slices: [], apps: [], gateways: [] };
|
||||
const labels = (dd.regions && dd.regions.length) ? dd.regions : ['No Deployments'];
|
||||
const valOrZero = arr => (Array.isArray(arr) && arr.length ? arr : [0]);
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: 'Nodes', data: valOrZero(dd.nodes), backgroundColor: '#007bff', borderWidth: 1 },
|
||||
{ label: 'Slices', data: valOrZero(dd.slices), backgroundColor: '#28a745', borderWidth: 1 },
|
||||
{ label: 'Apps', data: valOrZero(dd.apps), backgroundColor: '#ffc107', borderWidth: 1 },
|
||||
{ label: 'Gateways', data: valOrZero(dd.gateways), backgroundColor: '#17a2b8', borderWidth: 1 }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { position: 'top' }, title: { display: true, text: 'Deployment Distribution by Region' } },
|
||||
scales: {
|
||||
x: { stacked: true, title: { display: true, text: 'Regions' } },
|
||||
y: { stacked: true, beginAtZero: true, title: { display: true, text: 'Number of Deployments' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initCharts();
|
||||
updateDashboardWalletBalance();
|
||||
});
|
||||
})();
|
605
src/static/js/dashboard_cart.js
Normal file
605
src/static/js/dashboard_cart.js
Normal file
@@ -0,0 +1,605 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Read hydration data safely
|
||||
function readHydration(id) {
|
||||
try {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return {};
|
||||
const txt = el.textContent || el.innerText || '';
|
||||
if (!txt.trim()) return {};
|
||||
return JSON.parse(txt);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const hyd = readHydration('hydration-dashboard-cart');
|
||||
const currencySymbol = (hyd && hyd.currency_symbol) || '$';
|
||||
const displayCurrency = (hyd && hyd.display_currency) || 'USD';
|
||||
|
||||
const showToast = (window.showToast) ? window.showToast : function (msg, type) {
|
||||
// Fallback: log to console in case toast helper isn't available
|
||||
const prefix = type === 'error' ? '[error]' : '[info]';
|
||||
try { console.log(prefix, msg); } catch (_) {}
|
||||
};
|
||||
|
||||
// Central 402 handler wrapper
|
||||
async function handle402(response, preReadText) {
|
||||
// Rely on global fetch interceptor in base.js to render the modal
|
||||
// This function now only signals the caller to stop normal error flow
|
||||
if (!response || response.status !== 402) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
const getCartItems = window.getCartItems || function () { return []; };
|
||||
|
||||
// Suppress cart load error toast in specific flows (e.g., right after clear)
|
||||
window._suppressCartLoadToast = false;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Initial loads
|
||||
loadCartItems();
|
||||
loadWalletBalance();
|
||||
|
||||
// Listen for cart updates
|
||||
window.addEventListener('cartUpdated', function () {
|
||||
loadCartItems();
|
||||
updateCartSummary();
|
||||
});
|
||||
|
||||
// Post-reload success toast for cart clear (logged-in)
|
||||
try {
|
||||
if (sessionStorage.getItem('cartCleared') === '1') {
|
||||
sessionStorage.removeItem('cartCleared');
|
||||
showToast('Cart cleared', 'success');
|
||||
}
|
||||
} catch (_) { /* storage may be unavailable */ }
|
||||
});
|
||||
|
||||
// Event delegation for all clickable actions
|
||||
document.addEventListener('click', function (e) {
|
||||
const actionEl = e.target.closest('[data-action]');
|
||||
if (!actionEl) return;
|
||||
|
||||
const action = actionEl.getAttribute('data-action');
|
||||
if (!action) return;
|
||||
|
||||
switch (action) {
|
||||
case 'increase-qty':
|
||||
e.preventDefault();
|
||||
increaseQuantity(actionEl);
|
||||
break;
|
||||
case 'decrease-qty':
|
||||
e.preventDefault();
|
||||
decreaseQuantity(actionEl);
|
||||
break;
|
||||
case 'remove-item':
|
||||
e.preventDefault();
|
||||
removeCartItem(actionEl);
|
||||
break;
|
||||
case 'save-for-later':
|
||||
e.preventDefault();
|
||||
saveCartForLater();
|
||||
break;
|
||||
case 'share-cart':
|
||||
e.preventDefault();
|
||||
shareCart();
|
||||
break;
|
||||
case 'proceed-checkout':
|
||||
e.preventDefault();
|
||||
proceedToCheckout();
|
||||
break;
|
||||
case 'confirm-clear-cart':
|
||||
e.preventDefault();
|
||||
clearCartConfirm();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, false);
|
||||
|
||||
async function loadCartItems() {
|
||||
try {
|
||||
// Fetch cart data from server API instead of localStorage
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const cartItems = (cartData && cartData.items) ? cartData.items : [];
|
||||
|
||||
const container = document.getElementById('cartItemsContainer');
|
||||
const emptyMessage = document.getElementById('emptyCartMessage');
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
emptyMessage.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(emptyMessage);
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
if (clearCartBtn) clearCartBtn.disabled = true;
|
||||
// Reset summary to zero when cart is empty
|
||||
const subtotalEl = document.getElementById('cartSubtotal');
|
||||
const totalEl = document.getElementById('cartTotal');
|
||||
const deployEl = document.getElementById('cartDeployTime');
|
||||
if (subtotalEl) subtotalEl.textContent = `${currencySymbol}0.00`;
|
||||
if (totalEl) totalEl.textContent = `${currencySymbol}0.00`;
|
||||
if (deployEl) deployEl.textContent = '0 minutes';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMessage.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
|
||||
cartItems.forEach(item => {
|
||||
const itemElement = createCartItemElement(item);
|
||||
container.appendChild(itemElement);
|
||||
});
|
||||
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (checkoutBtn) checkoutBtn.disabled = false;
|
||||
if (clearCartBtn) clearCartBtn.disabled = false;
|
||||
|
||||
// Update cart summary with server data
|
||||
updateCartSummary(cartData);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading cart items:', error);
|
||||
|
||||
// Only show error toast for actual server errors (and not when suppressed)
|
||||
if (!window._suppressCartLoadToast) {
|
||||
if (error.message && !error.message.includes('404') && !error.message.includes('empty')) {
|
||||
showToast('Failed to load cart items', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to empty state (this is normal when cart is empty)
|
||||
const container = document.getElementById('cartItemsContainer');
|
||||
const emptyMessage = document.getElementById('emptyCartMessage');
|
||||
emptyMessage.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(emptyMessage);
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
if (clearCartBtn) clearCartBtn.disabled = true;
|
||||
} finally {
|
||||
// Always reset suppression flag after attempt
|
||||
window._suppressCartLoadToast = false;
|
||||
}
|
||||
}
|
||||
|
||||
function createCartItemElement(item) {
|
||||
const template = document.getElementById('cartItemTemplate');
|
||||
const element = template.content.cloneNode(true);
|
||||
|
||||
const container = element.querySelector('.cart-item');
|
||||
container.setAttribute('data-item-id', item.product_id);
|
||||
|
||||
// Use correct field names from cart API response
|
||||
element.querySelector('.service-name').textContent = item.product_name || 'Unknown Service';
|
||||
element.querySelector('.service-specs').textContent = formatSpecs(item.specifications);
|
||||
element.querySelector('.service-price').textContent = item.total_price || '0.00 USD';
|
||||
|
||||
// Set the quantity display
|
||||
element.querySelector('.quantity-display').textContent = item.quantity || 1;
|
||||
|
||||
// Show provider name without quantity (quantity is now in controls)
|
||||
element.querySelector('.added-time').textContent = item.provider_name || 'Provider';
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function formatSpecs(specs) {
|
||||
if (!specs || Object.keys(specs).length === 0) {
|
||||
return 'Standard configuration';
|
||||
}
|
||||
|
||||
const specParts = [];
|
||||
if (specs.cpu) specParts.push(`${specs.cpu} CPU`);
|
||||
if (specs.memory) specParts.push(`${specs.memory}GB RAM`);
|
||||
if (specs.storage) specParts.push(`${specs.storage}GB Storage`);
|
||||
if (specs.bandwidth) specParts.push(`${specs.bandwidth} Bandwidth`);
|
||||
|
||||
if (specParts.length === 0) {
|
||||
for (const [key, value] of Object.entries(specs)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
specParts.push(`${key}: ${value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return specParts.length > 0 ? specParts.join(' • ') : 'Standard configuration';
|
||||
}
|
||||
|
||||
function formatTime(timestamp) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (minutes < 1440) return `${Math.floor(minutes / 60)}h ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function updateCartSummary(cartData) {
|
||||
// Use server cart data if provided, otherwise fetch from localStorage as fallback
|
||||
let cartItems, subtotal, total;
|
||||
|
||||
if (cartData) {
|
||||
cartItems = cartData.items || [];
|
||||
subtotal = cartData.subtotal || '0.00';
|
||||
total = cartData.total || '0.00';
|
||||
} else {
|
||||
cartItems = getCartItems();
|
||||
subtotal = cartItems.reduce((sum, item) => sum + (item.price_usd || item.price_tfc || 0), 0);
|
||||
total = subtotal;
|
||||
}
|
||||
|
||||
const deployTime = cartItems.length * 2; // Estimate 2 minutes per service
|
||||
|
||||
const subtotalValue = typeof subtotal === 'string' ? parseFloat(subtotal.replace(/[^0-9.-]/g, '')) : subtotal;
|
||||
const totalValue = typeof total === 'string' ? parseFloat(total.replace(/[^0-9.-]/g, '')) : total;
|
||||
|
||||
const subtotalEl = document.getElementById('cartSubtotal');
|
||||
const totalEl = document.getElementById('cartTotal');
|
||||
const deployEl = document.getElementById('cartDeployTime');
|
||||
if (subtotalEl) subtotalEl.textContent = `${currencySymbol}${subtotalValue.toFixed(2)}`;
|
||||
if (totalEl) totalEl.textContent = `${currencySymbol}${totalValue.toFixed(2)}`;
|
||||
if (deployEl) deployEl.textContent = `${deployTime} minutes`;
|
||||
|
||||
// Update balance indicator with USD amount
|
||||
updateBalanceIndicator(totalValue);
|
||||
|
||||
// Update checkout button state based on cart contents
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
|
||||
if (cartItems.length === 0 || totalValue <= 0) {
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
if (clearCartBtn) clearCartBtn.disabled = true;
|
||||
} else {
|
||||
if (checkoutBtn) checkoutBtn.disabled = false;
|
||||
if (clearCartBtn) clearCartBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Load current wallet balance and update display
|
||||
loadWalletBalance();
|
||||
}
|
||||
|
||||
function updateBalanceIndicator(totalCost) {
|
||||
const balanceIndicator = document.getElementById('balanceIndicator');
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
|
||||
if (!balanceIndicator) return;
|
||||
|
||||
if (totalCost === 0) {
|
||||
balanceIndicator.innerHTML = '';
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const numericTotal = Number(totalCost);
|
||||
if (!Number.isFinite(numericTotal) || numericTotal <= 0) {
|
||||
balanceIndicator.innerHTML = '';
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await window.apiJson(`/api/wallet/check-affordability?amount=${numericTotal.toFixed(2)}`, { method: 'GET' });
|
||||
if (payload && payload.can_afford) {
|
||||
balanceIndicator.innerHTML = `
|
||||
<div class="alert alert-success py-2 mb-0">
|
||||
<i class="bi bi-check-circle me-1"></i>
|
||||
<small>Sufficient ${displayCurrency} credits for checkout</small>
|
||||
</div>
|
||||
`;
|
||||
if (checkoutBtn) checkoutBtn.disabled = false;
|
||||
} else {
|
||||
const shortfall = (payload && payload.shortfall != null
|
||||
? Number(payload.shortfall)
|
||||
: (payload && payload.shortfall_info && Number(payload.shortfall_info.shortfall))
|
||||
) || 0;
|
||||
balanceIndicator.innerHTML = `
|
||||
<div class="alert alert-warning py-2 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
<small>Insufficient ${displayCurrency} credits. Need ${currencySymbol}${shortfall.toFixed(2)} more.</small>
|
||||
<br><a href="/dashboard/wallet" class="btn btn-sm btn-outline-primary mt-1">
|
||||
<i class="bi bi-plus-circle me-1"></i>Top Up Wallet
|
||||
</a>
|
||||
</div>
|
||||
`;
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// Let global 402 handler display modal; suppress extra UI noise here
|
||||
if (error && error.status === 402) {
|
||||
return;
|
||||
}
|
||||
console.error('Error checking affordability:', error);
|
||||
balanceIndicator.innerHTML = `
|
||||
<div class="alert alert-secondary py-2 mb-0">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<small>Unable to verify balance. Please try again.</small>
|
||||
</div>
|
||||
`;
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Load current wallet balance from API
|
||||
function loadWalletBalance() {
|
||||
(async () => {
|
||||
try {
|
||||
const payload = await window.apiJson('/api/wallet/balance', { method: 'GET' });
|
||||
const balance = parseFloat(payload.balance) || parseFloat(payload.new_balance) || 0;
|
||||
const balEl = document.getElementById('userBalance');
|
||||
if (balEl) balEl.textContent = `${currencySymbol}${balance.toFixed(2)}`;
|
||||
} catch (error) {
|
||||
console.error('Error loading wallet balance:', error);
|
||||
// Keep existing balance display on error
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function removeCartItem(button) {
|
||||
const cartItem = button.closest('.cart-item');
|
||||
if (!cartItem) return;
|
||||
const itemId = cartItem.getAttribute('data-item-id');
|
||||
|
||||
// Show loading state
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<i class="bi bi-hourglass-split"></i>';
|
||||
|
||||
try {
|
||||
// Use server API to remove item
|
||||
await window.apiJson(`/api/cart/item/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
// Remove item from DOM immediately for better UX
|
||||
cartItem.style.transition = 'opacity 0.3s ease';
|
||||
cartItem.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
cartItem.remove();
|
||||
// Update cart summary after removal
|
||||
updateCartSummary();
|
||||
// Re-check affordability based on current displayed total
|
||||
const totalEl = document.getElementById('cartTotal');
|
||||
const currentTotal = totalEl ? parseFloat(totalEl.textContent.replace(/[^0-9.-]/g, '')) : 0;
|
||||
updateBalanceIndicator(currentTotal);
|
||||
}, 300);
|
||||
|
||||
showToast('Item removed from cart', 'success');
|
||||
|
||||
// Update navbar cart count immediately
|
||||
if (window.updateCartCount) {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
// Update cart summary after DOM changes
|
||||
updateCartSummary();
|
||||
|
||||
// Check if cart is now empty and handle UI accordingly
|
||||
const remainingItems = document.querySelectorAll('.cart-item');
|
||||
if (remainingItems.length === 0) {
|
||||
const container = document.getElementById('cartItemsContainer');
|
||||
const emptyMessage = document.getElementById('emptyCartMessage');
|
||||
if (emptyMessage) {
|
||||
emptyMessage.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(emptyMessage);
|
||||
const checkoutBtn = document.getElementById('checkoutBtn');
|
||||
const clearCartBtn = document.getElementById('clearCartBtn');
|
||||
if (checkoutBtn) checkoutBtn.disabled = true;
|
||||
if (clearCartBtn) clearCartBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing cart item:', error);
|
||||
if (error && error.status === 402) {
|
||||
// Reset button state if blocked by insufficient funds
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
return;
|
||||
}
|
||||
showToast(`Failed to remove item: ${error.message}`, 'error');
|
||||
// Reset button state on error
|
||||
button.disabled = false;
|
||||
button.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Increase quantity of cart item
|
||||
async function increaseQuantity(button) {
|
||||
const cartItem = button.closest('.cart-item');
|
||||
if (!cartItem) return;
|
||||
const itemId = cartItem.getAttribute('data-item-id');
|
||||
const quantityDisplay = cartItem.querySelector('.quantity-display');
|
||||
const currentQuantity = parseInt(quantityDisplay.textContent);
|
||||
const newQuantity = currentQuantity + 1;
|
||||
await updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem);
|
||||
}
|
||||
|
||||
// Decrease quantity of cart item
|
||||
async function decreaseQuantity(button) {
|
||||
const cartItem = button.closest('.cart-item');
|
||||
if (!cartItem) return;
|
||||
const itemId = cartItem.getAttribute('data-item-id');
|
||||
const quantityDisplay = cartItem.querySelector('.quantity-display');
|
||||
const currentQuantity = parseInt(quantityDisplay.textContent);
|
||||
|
||||
if (currentQuantity <= 1) {
|
||||
// If quantity is 1 or less, remove the item instead
|
||||
const removeBtn = cartItem.querySelector('[data-action="remove-item"]');
|
||||
if (removeBtn) removeCartItem(removeBtn);
|
||||
return;
|
||||
}
|
||||
|
||||
const newQuantity = currentQuantity - 1;
|
||||
await updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem);
|
||||
}
|
||||
|
||||
// Update cart item quantity via API
|
||||
async function updateCartItemQuantity(itemId, newQuantity, quantityDisplay, cartItem) {
|
||||
// Show loading state
|
||||
const originalQuantity = quantityDisplay.textContent;
|
||||
quantityDisplay.textContent = '...';
|
||||
|
||||
try {
|
||||
await window.apiJson(`/api/cart/item/${itemId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quantity: newQuantity })
|
||||
});
|
||||
|
||||
// Update quantity display
|
||||
quantityDisplay.textContent = newQuantity;
|
||||
|
||||
// Calculate and update the new total price for this item
|
||||
const priceElement = cartItem.querySelector('.service-price');
|
||||
const currentTotalText = priceElement.textContent;
|
||||
const currentTotal = parseFloat(currentTotalText.replace(/[^0-9.-]/g, ''));
|
||||
const oldQuantity = parseInt(originalQuantity);
|
||||
const unitPrice = currentTotal / oldQuantity;
|
||||
const newTotalPrice = unitPrice * newQuantity;
|
||||
|
||||
// Update the price display with new total
|
||||
priceElement.textContent = `${currencySymbol}${newTotalPrice.toFixed(2)}`;
|
||||
|
||||
// Fetch fresh cart data and update summary
|
||||
try {
|
||||
const freshCartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
if (freshCartData) updateCartSummary(freshCartData);
|
||||
} catch (_) { /* ignore refresh error */ }
|
||||
|
||||
// Update navbar count
|
||||
if (window.updateCartCount) {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
showToast(`Quantity updated to ${newQuantity}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error updating quantity:', error);
|
||||
if (error && error.status === 402) {
|
||||
// Restore original quantity if blocked
|
||||
quantityDisplay.textContent = originalQuantity;
|
||||
return;
|
||||
}
|
||||
// Restore original quantity on error
|
||||
quantityDisplay.textContent = originalQuantity;
|
||||
showToast(`Failed to update quantity: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedToCheckout() {
|
||||
try {
|
||||
// Check server cart state before proceeding
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const cartItems = cartData.items || [];
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
showToast('Cart is empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to checkout
|
||||
window.location.href = '/checkout';
|
||||
} catch (error) {
|
||||
console.error('Error checking cart before checkout:', error);
|
||||
showToast('Failed to proceed to checkout', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCartConfirm() {
|
||||
// Hide modal if open
|
||||
const modalEl = document.getElementById('clearCartModal');
|
||||
if (modalEl && window.bootstrap && typeof window.bootstrap.Modal?.getInstance === 'function') {
|
||||
const modalInstance = window.bootstrap.Modal.getInstance(modalEl);
|
||||
if (modalInstance) modalInstance.hide();
|
||||
}
|
||||
|
||||
try {
|
||||
// Use server API to clear cart
|
||||
await window.apiJson('/api/cart', { method: 'DELETE' });
|
||||
|
||||
// Emit event and update navbar first, then reload page to ensure fresh state
|
||||
window._suppressCartLoadToast = true;
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(0); }
|
||||
if (typeof window.updateCartCount === 'function') { window.updateCartCount(); }
|
||||
try { sessionStorage.setItem('cartCleared', '1'); } catch (_) {}
|
||||
setTimeout(() => { window.location.reload(); }, 50);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error clearing cart:', error);
|
||||
if (error && error.status === 402) {
|
||||
return;
|
||||
}
|
||||
showToast('Failed to clear cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCartForLater() {
|
||||
try {
|
||||
// Get cart data from server
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const cartItems = cartData.items || [];
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
showToast('Cart is empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to localStorage with timestamp for later retrieval
|
||||
localStorage.setItem('saved_cart_' + Date.now(), JSON.stringify(cartItems));
|
||||
showToast('Cart saved for later', 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving cart for later:', error);
|
||||
showToast('Failed to save cart', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function shareCart() {
|
||||
try {
|
||||
// Get cart data from server
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const cartItems = cartData.items || [];
|
||||
|
||||
if (cartItems.length === 0) {
|
||||
showToast('Cart is empty', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create shareable cart data using server response format
|
||||
const shareData = {
|
||||
items: cartItems.map(item => ({
|
||||
product_id: item.product_id,
|
||||
name: item.name,
|
||||
price: item.price,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
subtotal: cartData.subtotal,
|
||||
total: cartData.total,
|
||||
currency: cartData.currency,
|
||||
created: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(JSON.stringify(shareData, null, 2)).then(() => {
|
||||
showToast('Cart data copied to clipboard', 'success');
|
||||
}).catch(() => {
|
||||
showToast('Failed to copy cart data', 'error');
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sharing cart:', error);
|
||||
showToast('Failed to share cart', 'error');
|
||||
}
|
||||
}
|
||||
})();
|
224
src/static/js/dashboard_layout.js
Normal file
224
src/static/js/dashboard_layout.js
Normal file
@@ -0,0 +1,224 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||||
else fn();
|
||||
}
|
||||
|
||||
function setupSidebar() {
|
||||
const sidebarToggleBtn = document.getElementById('sidebarToggleBtn');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
||||
if (!sidebarToggleBtn || !sidebar || !sidebarBackdrop) return;
|
||||
|
||||
// Ensure clean state on page load
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Toggle sidebar visibility
|
||||
sidebarToggleBtn.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// Toggle visibility
|
||||
sidebar.classList.toggle('show');
|
||||
sidebarBackdrop.classList.toggle('show');
|
||||
|
||||
// Set aria-expanded for accessibility
|
||||
const isExpanded = sidebar.classList.contains('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', String(isExpanded));
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on backdrop
|
||||
sidebarBackdrop.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on any link inside it
|
||||
const sidebarLinks = sidebar.querySelectorAll('a.nav-link');
|
||||
sidebarLinks.forEach(link => {
|
||||
link.addEventListener('click', function () {
|
||||
// Let the link work, then close
|
||||
setTimeout(function () {
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure links are clickable
|
||||
sidebar.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function initializeCartIntegration() {
|
||||
if (typeof window.updateCartCount !== 'function') {
|
||||
// define if missing
|
||||
window.updateCartCount = updateCartCount;
|
||||
}
|
||||
|
||||
// initial
|
||||
updateCartCount();
|
||||
|
||||
// Update cart count every 30 seconds
|
||||
setInterval(updateCartCount, 30000);
|
||||
|
||||
// Listen for cart updates from other tabs/windows
|
||||
window.addEventListener('storage', function (e) {
|
||||
if (e.key === 'cart_items') {
|
||||
updateCartCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for custom cart update events
|
||||
window.addEventListener('cartUpdated', function () {
|
||||
updateCartCount();
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCartCount() {
|
||||
try {
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' }) || {};
|
||||
const cartCount = parseInt(cartData.item_count) || 0;
|
||||
|
||||
// Update sidebar cart counter
|
||||
const cartBadge = document.getElementById('cartItemCount');
|
||||
if (cartBadge) {
|
||||
if (cartCount > 0) {
|
||||
cartBadge.textContent = String(cartCount);
|
||||
cartBadge.style.display = 'flex';
|
||||
} else {
|
||||
cartBadge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Update main navbar cart counter (from base.html)
|
||||
const navbarCartCount = document.querySelector('.cart-count');
|
||||
const navbarCartItem = document.getElementById('cartNavItem');
|
||||
if (navbarCartCount && navbarCartItem) {
|
||||
if (cartCount > 0) {
|
||||
navbarCartCount.textContent = String(cartCount);
|
||||
navbarCartCount.style.display = 'inline';
|
||||
navbarCartItem.style.display = 'block';
|
||||
} else {
|
||||
navbarCartCount.style.display = 'none';
|
||||
navbarCartItem.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Hide counts on error
|
||||
const navbarCartCount = document.querySelector('.cart-count');
|
||||
const navbarCartItem = document.getElementById('cartNavItem');
|
||||
if (navbarCartCount && navbarCartItem) {
|
||||
navbarCartCount.style.display = 'none';
|
||||
navbarCartItem.style.display = 'none';
|
||||
}
|
||||
// Keep console error minimal
|
||||
// console.error('Error updating dashboard cart count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Expose minimal cart helpers used across dashboard (legacy localStorage-based)
|
||||
window.addToCart = function (serviceId, serviceName, price, specs) {
|
||||
try {
|
||||
const cartItems = JSON.parse(localStorage.getItem('cart_items') || '[]');
|
||||
const existingItem = cartItems.find(item => item.service_id === serviceId);
|
||||
if (existingItem) {
|
||||
window.showToast('Item already in cart', 'info');
|
||||
return false;
|
||||
}
|
||||
const newItem = {
|
||||
id: 'cart_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
service_id: serviceId,
|
||||
service_name: serviceName,
|
||||
price_tfc: price,
|
||||
specs: specs,
|
||||
added_at: new Date().toISOString()
|
||||
};
|
||||
cartItems.push(newItem);
|
||||
localStorage.setItem('cart_items', JSON.stringify(cartItems));
|
||||
updateCartCount();
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
|
||||
window.showToast('Added to cart successfully', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
window.showToast('Failed to add to cart', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.removeFromCart = function (itemId) {
|
||||
try {
|
||||
const cartItems = JSON.parse(localStorage.getItem('cart_items') || '[]');
|
||||
const updatedItems = cartItems.filter(item => item.id !== itemId);
|
||||
localStorage.setItem('cart_items', JSON.stringify(updatedItems));
|
||||
updateCartCount();
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
|
||||
window.showToast('Removed from cart', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
window.showToast('Failed to remove from cart', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
window.getCartItems = function () {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('cart_items') || '[]');
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
window.clearCart = function () {
|
||||
try {
|
||||
localStorage.removeItem('cart_items');
|
||||
updateCartCount();
|
||||
if (typeof window.emitCartUpdated === 'function') { window.emitCartUpdated(); }
|
||||
window.showToast('Cart cleared', 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
window.showToast('Failed to clear cart', 'error');
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Toast helper
|
||||
window.showToast = function (message, type = 'info') {
|
||||
let toastContainer = document.getElementById('toastContainer');
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.id = 'toastContainer';
|
||||
toastContainer.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; max-width: 300px;';
|
||||
document.body.appendChild(toastContainer);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
const bgColor = type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : '#007bff';
|
||||
toast.style.cssText = 'background-color: ' + bgColor + '; color: white; padding: 12px 16px; border-radius: 6px; margin-bottom: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); opacity: 0; transform: translateX(100%); transition: all 0.3s ease;';
|
||||
toast.textContent = message;
|
||||
toastContainer.appendChild(toast);
|
||||
setTimeout(function () {
|
||||
toast.style.opacity = '1';
|
||||
toast.style.transform = 'translateX(0)';
|
||||
}, 100);
|
||||
setTimeout(function () {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'translateX(100%)';
|
||||
setTimeout(function () {
|
||||
if (toast.parentNode) toast.parentNode.removeChild(toast);
|
||||
}, 300);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
onReady(function () {
|
||||
setupSidebar();
|
||||
initializeCartIntegration();
|
||||
});
|
||||
})();
|
362
src/static/js/dashboard_orders.js
Normal file
362
src/static/js/dashboard_orders.js
Normal file
@@ -0,0 +1,362 @@
|
||||
// Dashboard Orders Page - CSP-compliant external script
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let HYDRATION = { currency_symbol: '$', display_currency: 'USD' };
|
||||
|
||||
function parseHydration() {
|
||||
try {
|
||||
const el = document.getElementById('orders-hydration');
|
||||
if (!el) return;
|
||||
const json = el.textContent || el.innerText || '{}';
|
||||
HYDRATION = Object.assign(HYDRATION, JSON.parse(json));
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse orders hydration JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function formatCurrencyAmount(amount) {
|
||||
const sym = HYDRATION.currency_symbol || '';
|
||||
const num = typeof amount === 'number' ? amount : parseFloat(amount) || 0;
|
||||
if (/^[A-Z]{2,}$/.test(sym)) {
|
||||
return `${num.toFixed(2)} ${sym}`;
|
||||
}
|
||||
return `${sym}${num.toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const payload = await window.apiJson('/api/orders', { method: 'GET' });
|
||||
const orders = payload.orders || [];
|
||||
const totalSpentFormatted = payload.total_spent || null;
|
||||
displayOrders(orders);
|
||||
updateOrderStats(orders, totalSpentFormatted);
|
||||
} catch (err) {
|
||||
console.error('Failed to load orders:', err);
|
||||
displayOrders([]);
|
||||
updateOrderStats([]);
|
||||
const ordersContainer = document.getElementById('ordersContainer');
|
||||
if (ordersContainer) {
|
||||
ordersContainer.innerHTML = (
|
||||
'<div class="alert alert-warning" role="alert">' +
|
||||
'<i class="bi bi-exclamation-triangle me-2"></i>' +
|
||||
'Unable to load orders. Please refresh the page or try again later.' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function displayOrders(orders) {
|
||||
const container = document.getElementById('ordersContainer');
|
||||
const noOrdersMessage = document.getElementById('noOrdersMessage');
|
||||
if (!container || !noOrdersMessage) return;
|
||||
|
||||
if (orders.length === 0) {
|
||||
noOrdersMessage.style.display = 'block';
|
||||
container.innerHTML = '';
|
||||
container.appendChild(noOrdersMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
noOrdersMessage.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
|
||||
orders.forEach((order) => {
|
||||
const node = createOrderElement(order);
|
||||
container.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
function createOrderElement(order) {
|
||||
const template = document.getElementById('orderItemTemplate');
|
||||
const fragment = template.content.cloneNode(true);
|
||||
const container = fragment.querySelector('.order-item');
|
||||
container.setAttribute('data-order-id', order.order_id);
|
||||
if (order.created_at) container.setAttribute('data-created-at', order.created_at);
|
||||
|
||||
const idEl = fragment.querySelector('.order-id');
|
||||
const statusEl = fragment.querySelector('.order-status');
|
||||
const servicesEl = fragment.querySelector('.order-services');
|
||||
const dateEl = fragment.querySelector('.order-date');
|
||||
const totalEl = fragment.querySelector('.order-total');
|
||||
|
||||
if (idEl) idEl.textContent = order.order_id;
|
||||
if (statusEl) {
|
||||
statusEl.textContent = (order.status || '').toString().toUpperCase();
|
||||
statusEl.className = `badge order-status ms-2 bg-${getStatusColor(order.status)}`;
|
||||
}
|
||||
if (servicesEl) servicesEl.textContent = `${(order.items || []).length} item(s): ${(order.items || []).map(s => s.product_name).join(', ')}`;
|
||||
if (dateEl) dateEl.textContent = formatOrderDate(new Date(order.created_at));
|
||||
if (totalEl) totalEl.textContent = order.total;
|
||||
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const colors = {
|
||||
pending: 'warning',
|
||||
processing: 'info',
|
||||
confirmed: 'success',
|
||||
completed: 'success',
|
||||
deployed: 'success',
|
||||
active: 'success',
|
||||
failed: 'danger',
|
||||
cancelled: 'secondary',
|
||||
};
|
||||
const key = (status || '').toString().toLowerCase();
|
||||
return colors[key] || 'secondary';
|
||||
}
|
||||
|
||||
function formatOrderDate(date) {
|
||||
const now = new Date();
|
||||
const diff = now - date;
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
if (days === 0) return 'Today';
|
||||
if (days === 1) return 'Yesterday';
|
||||
if (days < 7) return `${days} days ago`;
|
||||
if (days < 30) return `${Math.floor(days / 7)} weeks ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
function updateOrderStats(orders, totalSpentFormatted) {
|
||||
const totalOrders = orders.length;
|
||||
const totalOrdersEl = document.getElementById('totalOrders');
|
||||
const totalSpentEl = document.getElementById('totalSpent');
|
||||
if (totalOrdersEl) totalOrdersEl.textContent = totalOrders;
|
||||
if (totalSpentEl) {
|
||||
if (totalSpentFormatted) {
|
||||
totalSpentEl.textContent = totalSpentFormatted;
|
||||
} else {
|
||||
const sum = orders.reduce((acc, o) => {
|
||||
const n = parseFloat((o.total || '').replace(/[^0-9.-]+/g, '')) || 0;
|
||||
return acc + n;
|
||||
}, 0);
|
||||
totalSpentEl.textContent = formatCurrencyAmount(sum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCartBadge() {
|
||||
try {
|
||||
const badge = document.getElementById('cartBadge');
|
||||
if (!badge) return;
|
||||
const cartData = await window.apiJson('/api/cart', { cache: 'no-store' });
|
||||
const count = parseInt(cartData.item_count) || 0;
|
||||
if (count > 0) {
|
||||
badge.textContent = count;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed updating cart badge:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function filterOrders() {
|
||||
const status = (document.getElementById('orderStatus')?.value || '').toLowerCase();
|
||||
const period = parseInt(document.getElementById('orderPeriod')?.value || '');
|
||||
const search = (document.getElementById('orderSearch')?.value || '').toLowerCase();
|
||||
|
||||
const items = document.querySelectorAll('.order-item');
|
||||
const now = new Date();
|
||||
let anyVisible = false;
|
||||
items.forEach((item) => {
|
||||
let show = true;
|
||||
|
||||
if (status) {
|
||||
const statusBadge = item.querySelector('.order-status');
|
||||
const text = statusBadge ? statusBadge.textContent.toLowerCase() : '';
|
||||
if (!text.includes(status)) show = false;
|
||||
}
|
||||
|
||||
if (show && period) {
|
||||
const ts = item.getAttribute('data-created-at');
|
||||
if (ts) {
|
||||
const created = new Date(ts);
|
||||
const cutoff = new Date(now.getTime() - period * 24 * 60 * 60 * 1000);
|
||||
if (created < cutoff) show = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (show && search) {
|
||||
const idText = item.querySelector('.order-id')?.textContent?.toLowerCase() || '';
|
||||
const servicesText = item.querySelector('.order-services')?.textContent?.toLowerCase() || '';
|
||||
if (!idText.includes(search) && !servicesText.includes(search)) show = false;
|
||||
}
|
||||
|
||||
item.style.display = show ? '' : 'none';
|
||||
if (show) anyVisible = true;
|
||||
});
|
||||
|
||||
const container = document.getElementById('ordersContainer');
|
||||
const noOrdersMessage = document.getElementById('noOrdersMessage');
|
||||
if (container && noOrdersMessage) {
|
||||
if (!anyVisible) {
|
||||
noOrdersMessage.style.display = 'block';
|
||||
if (!container.contains(noOrdersMessage)) container.appendChild(noOrdersMessage);
|
||||
} else {
|
||||
noOrdersMessage.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('Filters applied', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function onContainerClick(e) {
|
||||
const actionEl = e.target.closest('[data-action]');
|
||||
if (!actionEl) return;
|
||||
const action = actionEl.getAttribute('data-action');
|
||||
|
||||
if (action === 'toggle-details') {
|
||||
e.preventDefault();
|
||||
const button = actionEl;
|
||||
const orderItem = button.closest('.order-item');
|
||||
if (!orderItem) return;
|
||||
const details = orderItem.querySelector('.order-details');
|
||||
if (!details) return;
|
||||
const isHidden = details.style.display === 'none' || details.style.display === '';
|
||||
if (isHidden) {
|
||||
details.style.display = 'block';
|
||||
button.innerHTML = '<i class="bi bi-eye-slash"></i> Hide';
|
||||
if (!details.dataset.loaded) {
|
||||
populateOrderDetails(details, orderItem.getAttribute('data-order-id'));
|
||||
details.dataset.loaded = 'true';
|
||||
}
|
||||
} else {
|
||||
details.style.display = 'none';
|
||||
button.innerHTML = '<i class="bi bi-eye"></i> Details';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'view-invoice') {
|
||||
e.preventDefault();
|
||||
const orderItem = actionEl.closest('.order-item');
|
||||
const orderId = orderItem?.getAttribute('data-order-id');
|
||||
if (!orderId) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Missing order id', 'danger');
|
||||
return;
|
||||
}
|
||||
window.open(`/orders/${encodeURIComponent(orderId)}/invoice`, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'contact-support') {
|
||||
e.preventDefault();
|
||||
const SUPPORT_URL = 'https://threefoldfaq.crisp.help/en/';
|
||||
window.open(SUPPORT_URL, '_blank');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function populateOrderDetails(detailsElement, orderId) {
|
||||
// Placeholder content; replace with real data when API is ready
|
||||
const servicesList = detailsElement.querySelector('.order-services-list');
|
||||
const deploymentStatus = detailsElement.querySelector('.deployment-status');
|
||||
|
||||
if (servicesList) {
|
||||
servicesList.innerHTML = (
|
||||
'<div class="list-group list-group-flush">' +
|
||||
'<div class="list-group-item d-flex justify-content-between">' +
|
||||
'<span>Ubuntu 22.04 VM (2 CPU, 4GB RAM)</span>' +
|
||||
'<span class="text-success">Active</span>' +
|
||||
'</div>' +
|
||||
'<div class="list-group-item d-flex justify-content-between">' +
|
||||
'<span>Storage Volume (100GB SSD)</span>' +
|
||||
'<span class="text-success">Active</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
if (deploymentStatus) {
|
||||
deploymentStatus.innerHTML = (
|
||||
'<div class="deployment-info">' +
|
||||
'<div class="d-flex justify-content-between mb-2">' +
|
||||
'<span>Deployment ID:</span>' +
|
||||
'<code>DEP-2001</code>' +
|
||||
'</div>' +
|
||||
'<div class="d-flex justify-content-between">' +
|
||||
'<span>Uptime:</span>' +
|
||||
'<span class="text-success">15 days</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function bindFilters() {
|
||||
const status = document.getElementById('orderStatus');
|
||||
const period = document.getElementById('orderPeriod');
|
||||
const search = document.getElementById('orderSearch');
|
||||
if (status) status.addEventListener('change', filterOrders);
|
||||
if (period) period.addEventListener('change', filterOrders);
|
||||
if (search) search.addEventListener('keyup', filterOrders);
|
||||
}
|
||||
|
||||
function bindInvoiceFromModal() {
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('[data-action="view-invoice-from-modal"]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
// Try to infer order id from any selected/visible order
|
||||
const visible = document.querySelector('.order-item');
|
||||
const orderId = visible?.getAttribute('data-order-id') || document.querySelector('.order-id')?.textContent?.trim();
|
||||
if (!orderId) {
|
||||
if (typeof window.showToast === 'function') window.showToast('Missing order id', 'danger');
|
||||
return;
|
||||
}
|
||||
window.open(`/orders/${encodeURIComponent(orderId)}/invoice`, '_blank', 'noopener');
|
||||
});
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
parseHydration();
|
||||
loadOrders();
|
||||
updateCartBadge();
|
||||
setInterval(updateCartBadge, 30000);
|
||||
|
||||
const container = document.getElementById('ordersContainer');
|
||||
if (container) container.addEventListener('click', onContainerClick);
|
||||
|
||||
bindFilters();
|
||||
bindInvoiceFromModal();
|
||||
|
||||
// Expose a public method for post-purchase refresh
|
||||
window.refreshOrders = loadOrders;
|
||||
|
||||
// Fallback toast if not provided by layout
|
||||
if (typeof window.showToast !== 'function') {
|
||||
window.showToast = function (message, type) {
|
||||
try {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type || 'info'} border-0 position-fixed end-0 m-3`;
|
||||
toast.style.top = '80px';
|
||||
toast.style.zIndex = '10000';
|
||||
toast.innerHTML = '<div class="d-flex">' +
|
||||
'<div class="toast-body">' +
|
||||
`<i class="bi bi-info-circle me-2"></i>${message}` +
|
||||
'</div>' +
|
||||
'<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>' +
|
||||
'</div>';
|
||||
document.body.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
||||
} catch (_) {
|
||||
console.log(`[${type || 'info'}] ${message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
})();
|
484
src/static/js/dashboard_pools.js
Normal file
484
src/static/js/dashboard_pools.js
Normal file
@@ -0,0 +1,484 @@
|
||||
/* Dashboard Pools Page JS - CSP compliant (no inline handlers) */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Toast helpers
|
||||
function showSuccessToast(message) {
|
||||
const body = document.getElementById('successToastBody');
|
||||
if (body) body.textContent = message;
|
||||
const toastEl = document.getElementById('successToast');
|
||||
if (toastEl && window.bootstrap) {
|
||||
const toast = bootstrap.Toast.getOrCreateInstance(toastEl);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
|
||||
function showErrorToast(message) {
|
||||
const body = document.getElementById('errorToastBody');
|
||||
if (body) body.textContent = message;
|
||||
const toastEl = document.getElementById('errorToast');
|
||||
if (toastEl && window.bootstrap) {
|
||||
const toast = bootstrap.Toast.getOrCreateInstance(toastEl);
|
||||
toast.show();
|
||||
}
|
||||
}
|
||||
|
||||
// Hydration
|
||||
function readHydration() {
|
||||
try {
|
||||
const el = document.getElementById('pools-hydration');
|
||||
if (!el) return {};
|
||||
return JSON.parse(el.textContent || '{}');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse pools hydration JSON', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Charts
|
||||
let charts = {};
|
||||
|
||||
function initCharts() {
|
||||
if (!window.Chart) return; // Chart.js not loaded
|
||||
|
||||
// Global defaults
|
||||
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
|
||||
Chart.defaults.font.size = 12;
|
||||
Chart.defaults.responsive = true;
|
||||
Chart.defaults.maintainAspectRatio = false;
|
||||
|
||||
const priceHistoryCtx = document.getElementById('creditsPriceHistoryChart');
|
||||
const liquidityCtx = document.getElementById('liquidityPoolDistributionChart');
|
||||
const volumeCtx = document.getElementById('exchangeVolumeChart');
|
||||
const stakingCtx = document.getElementById('stakingDistributionChart');
|
||||
|
||||
if (priceHistoryCtx) {
|
||||
charts.priceHistory = new Chart(priceHistoryCtx.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: ['Dec', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Credits-EUR Rate',
|
||||
data: [0.82, 0.84, 0.83, 0.85, 0.85, 0.86, 0.85],
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Credits-TFT Rate',
|
||||
data: [4.8, 4.9, 5.0, 5.1, 5.0, 4.9, 5.0],
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: false, title: { display: true, text: 'Exchange Rate' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (liquidityCtx) {
|
||||
charts.liquidity = new Chart(liquidityCtx.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Credits-Fiat Pool', 'Credits-TFT Pool', 'Credits-PEAQ Pool'],
|
||||
datasets: [
|
||||
{
|
||||
data: [1250000, 250000, 100000],
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8'],
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { boxWidth: 12 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (volumeCtx) {
|
||||
charts.volume = new Chart(volumeCtx.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
|
||||
datasets: [
|
||||
{ label: 'Credits-Fiat', data: [2500, 3200, 2800, 3500], backgroundColor: '#007bff', borderWidth: 1 },
|
||||
{ label: 'Credits-TFT', data: [1500, 1800, 2200, 2000], backgroundColor: '#28a745', borderWidth: 1 },
|
||||
{ label: 'Credits-PEAQ', data: [800, 1000, 1200, 900], backgroundColor: '#17a2b8', borderWidth: 1 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Volume (USD)' }, stacked: true },
|
||||
x: { stacked: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (stakingCtx) {
|
||||
charts.staking = new Chart(stakingCtx.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: ['$10-50', '$51-100', '$101-500', '$501+'],
|
||||
datasets: [
|
||||
{ data: [450, 280, 150, 75], backgroundColor: ['#007bff', '#28a745', '#ffc107', '#dc3545'], borderWidth: 1 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'right', labels: { boxWidth: 12 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPoolData() {
|
||||
try {
|
||||
const pools = await window.apiJson('/api/pools', { cache: 'no-store' });
|
||||
(Array.isArray(pools) ? pools : []).forEach(updatePoolCard);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load /api/pools', e);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePoolCard(pool) {
|
||||
const card = document.querySelector(`[data-pool-id="${pool.id}"]`);
|
||||
if (!card) return;
|
||||
const rateEl = card.querySelector('.exchange-rate');
|
||||
const liqEl = card.querySelector('.liquidity');
|
||||
if (rateEl) rateEl.textContent = `1 ${pool.token_a} = ${pool.exchange_rate} ${pool.token_b}`;
|
||||
if (liqEl) liqEl.textContent = `${(pool.liquidity || 0).toLocaleString()} ${pool.token_a}`;
|
||||
}
|
||||
|
||||
async function loadAnalyticsData() {
|
||||
try {
|
||||
const analytics = await window.apiJson('/api/pools/analytics', { cache: 'no-store' });
|
||||
if (analytics) updateChartsWithRealData(analytics);
|
||||
} catch (e) {
|
||||
console.warn('Failed to load /api/pools/analytics', e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateChartsWithRealData(analytics) {
|
||||
if (analytics.price_history && charts.priceHistory) {
|
||||
const labels = analytics.price_history.map((p) => new Date(p.timestamp).toLocaleDateString());
|
||||
const prices = analytics.price_history.map((p) => p.price);
|
||||
charts.priceHistory.data.labels = labels;
|
||||
charts.priceHistory.data.datasets[0].data = prices;
|
||||
charts.priceHistory.update();
|
||||
}
|
||||
if (analytics.liquidity_distribution && charts.liquidity) {
|
||||
const labels = Object.keys(analytics.liquidity_distribution);
|
||||
const data = Object.values(analytics.liquidity_distribution);
|
||||
charts.liquidity.data.labels = labels;
|
||||
charts.liquidity.data.datasets[0].data = data;
|
||||
charts.liquidity.update();
|
||||
}
|
||||
if (analytics.staking_distribution && charts.staking) {
|
||||
const labels = Object.keys(analytics.staking_distribution);
|
||||
const data = Object.values(analytics.staking_distribution);
|
||||
charts.staking.data.labels = labels;
|
||||
charts.staking.data.datasets[0].data = data;
|
||||
charts.staking.update();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculators
|
||||
function setupCalculators() {
|
||||
// Buy with TFT
|
||||
const tfpAmountTFT = document.getElementById('tfpAmountTFT');
|
||||
if (tfpAmountTFT) {
|
||||
tfpAmountTFT.addEventListener('input', () => {
|
||||
const amount = parseFloat(tfpAmountTFT.value) || 0;
|
||||
const tftCost = amount * 0.5; // 1 TFC = 0.5 TFT
|
||||
const modal = document.getElementById('buyTFCWithTFTModal');
|
||||
if (modal) {
|
||||
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
|
||||
// rows[0] -> Amount, rows[1] -> Cost
|
||||
if (rows[0]) rows[0].textContent = `${amount} TFC`;
|
||||
if (rows[1]) rows[1].textContent = `${tftCost.toFixed(1)} TFT`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sell for TFT
|
||||
const sellTfpAmountTFT = document.getElementById('sellTfpAmountTFT');
|
||||
if (sellTfpAmountTFT) {
|
||||
sellTfpAmountTFT.addEventListener('input', () => {
|
||||
const amount = parseFloat(sellTfpAmountTFT.value) || 0;
|
||||
const tftReceive = amount * 0.5; // 1 TFC = 0.5 TFT
|
||||
const modal = document.getElementById('sellTFCForTFTModal');
|
||||
if (modal) {
|
||||
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
|
||||
if (rows[0]) rows[0].textContent = `${amount} TFC`;
|
||||
if (rows[1]) rows[1].textContent = `${tftReceive.toFixed(1)} TFT`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Buy with PEAQ
|
||||
const tfpAmountPEAQ = document.getElementById('tfpAmountPEAQ');
|
||||
if (tfpAmountPEAQ) {
|
||||
tfpAmountPEAQ.addEventListener('input', () => {
|
||||
const amount = parseFloat(tfpAmountPEAQ.value) || 0;
|
||||
const peaqCost = amount * 2.0; // 1 TFC = 2 PEAQ
|
||||
const modal = document.getElementById('buyTFCWithPEAQModal');
|
||||
if (modal) {
|
||||
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
|
||||
if (rows[0]) rows[0].textContent = `${amount} TFC`;
|
||||
if (rows[1]) rows[1].textContent = `${peaqCost.toFixed(0)} PEAQ`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sell for PEAQ
|
||||
const sellTfpAmountPEAQ = document.getElementById('sellTfpAmountPEAQ');
|
||||
if (sellTfpAmountPEAQ) {
|
||||
sellTfpAmountPEAQ.addEventListener('input', () => {
|
||||
const amount = parseFloat(sellTfpAmountPEAQ.value) || 0;
|
||||
const peaqReceive = amount * 2.0; // 1 TFC = 2 PEAQ
|
||||
const modal = document.getElementById('sellTFCForPEAQModal');
|
||||
if (modal) {
|
||||
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
|
||||
if (rows[0]) rows[0].textContent = `${amount} TFC`;
|
||||
if (rows[1]) rows[1].textContent = `${peaqReceive.toFixed(0)} PEAQ`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Optional: Sell Credits for fiat quick calculator using simple FX placeholders
|
||||
const sellCreditsAmount = document.getElementById('sellCreditsAmount');
|
||||
const receiveCurrency = document.getElementById('receiveCurrency');
|
||||
if (sellCreditsAmount && receiveCurrency) {
|
||||
const updateReceive = () => {
|
||||
const amount = parseFloat(sellCreditsAmount.value) || 0;
|
||||
const ccy = receiveCurrency.value;
|
||||
let rate = 1.0; // 1 Credit = 1 USD base
|
||||
if (ccy === 'EUR') rate = 0.9; // placeholder
|
||||
if (ccy === 'GBP') rate = 0.8; // placeholder
|
||||
const receive = amount * rate;
|
||||
const modal = document.getElementById('sellCreditsModal');
|
||||
if (modal) {
|
||||
// Find the last text-end in alert -> corresponds to You receive
|
||||
const rows = modal.querySelectorAll('.alert .d-flex.justify-content-between .text-end');
|
||||
if (rows[1]) rows[1].textContent = `${receive.toFixed(2)} ${ccy}`;
|
||||
}
|
||||
};
|
||||
sellCreditsAmount.addEventListener('input', updateReceive);
|
||||
receiveCurrency.addEventListener('change', updateReceive);
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation for actions
|
||||
async function handleAction(action) {
|
||||
switch (action) {
|
||||
case 'confirm-buy-credits-fiat': {
|
||||
const amount = parseFloat(document.getElementById('creditsAmount')?.value);
|
||||
const paymentMethod = document.getElementById('paymentMethod')?.value;
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/buy-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, payment_method: paymentMethod }),
|
||||
});
|
||||
showSuccessToast('Credits purchase successful');
|
||||
const modal = document.getElementById('buyCreditsModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
if (document.getElementById('creditsAmount')) document.getElementById('creditsAmount').value = '';
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Insufficient funds handled globally via 402 interceptor (opens credit modal)
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Purchase failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-sell-credits-fiat': {
|
||||
const amount = parseFloat(document.getElementById('sellCreditsAmount')?.value);
|
||||
const currency = document.getElementById('receiveCurrency')?.value;
|
||||
const payout_method = document.getElementById('payoutMethod')?.value;
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/sell-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, currency, payout_method }),
|
||||
});
|
||||
showSuccessToast('Credits sale successful');
|
||||
const modal = document.getElementById('sellCreditsModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
if (document.getElementById('sellCreditsAmount')) document.getElementById('sellCreditsAmount').value = '';
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally via credit modal
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Sale failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-stake-credits': {
|
||||
const amount = parseFloat(document.getElementById('stakeAmount')?.value);
|
||||
const duration_months = parseInt(document.getElementById('stakeDuration')?.value, 10);
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/pools/stake', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, duration_months }),
|
||||
});
|
||||
showSuccessToast(`Successfully staked $${amount} for ${duration_months} months`);
|
||||
const modal = document.getElementById('stakeCreditsModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
if (document.getElementById('stakeAmount')) document.getElementById('stakeAmount').value = '';
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Staking failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-buy-tfc-tft': {
|
||||
const amount = parseFloat(document.getElementById('tfpAmountTFT')?.value);
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/buy-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, payment_method: 'TFT' }),
|
||||
});
|
||||
showSuccessToast(`Purchased ${amount} TFC with TFT`);
|
||||
const modal = document.getElementById('buyTFCWithTFTModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Purchase failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-sell-tfc-tft': {
|
||||
const amount = parseFloat(document.getElementById('sellTfpAmountTFT')?.value);
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/sell-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, currency: 'TFT', payout_method: 'blockchain' }),
|
||||
});
|
||||
showSuccessToast(`Sold ${amount} TFC for TFT`);
|
||||
const modal = document.getElementById('sellTFCForTFTModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Sale failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-buy-tfc-peaq': {
|
||||
const amount = parseFloat(document.getElementById('tfpAmountPEAQ')?.value);
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/buy-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, payment_method: 'PEAQ' }),
|
||||
});
|
||||
showSuccessToast(`Purchased ${amount} TFC with PEAQ`);
|
||||
const modal = document.getElementById('buyTFCWithPEAQModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Purchase failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'confirm-sell-tfc-peaq': {
|
||||
const amount = parseFloat(document.getElementById('sellTfpAmountPEAQ')?.value);
|
||||
if (!amount || amount <= 0) return showErrorToast('Enter a valid amount');
|
||||
try {
|
||||
await window.apiJson('/api/wallet/sell-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount, currency: 'PEAQ', payout_method: 'blockchain' }),
|
||||
});
|
||||
showSuccessToast(`Sold ${amount} TFC for PEAQ`);
|
||||
const modal = document.getElementById('sellTFCForPEAQModal');
|
||||
if (modal && window.bootstrap) bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||
} catch (e) {
|
||||
if (e && e.status === 402) {
|
||||
// Handled globally
|
||||
} else {
|
||||
showErrorToast(e?.message || 'Sale failed');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventDelegation() {
|
||||
document.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-action]');
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute('data-action');
|
||||
if (!action) return;
|
||||
handleAction(action);
|
||||
});
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hydration = readHydration();
|
||||
if (hydration.charts) initCharts();
|
||||
// data loads (best-effort)
|
||||
loadPoolData();
|
||||
loadAnalyticsData();
|
||||
setupCalculators();
|
||||
setupEventDelegation();
|
||||
});
|
||||
})();
|
488
src/static/js/dashboard_wallet.js
Normal file
488
src/static/js/dashboard_wallet.js
Normal file
@@ -0,0 +1,488 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Read hydration data safely
|
||||
function readHydration(id) {
|
||||
try {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return {};
|
||||
const txt = el.textContent || el.innerText || '';
|
||||
if (!txt.trim()) return {};
|
||||
return JSON.parse(txt);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const hyd = readHydration('wallet-hydration');
|
||||
const currencySymbol = (hyd && hyd.currency_symbol) || '$';
|
||||
const displayCurrency = (hyd && hyd.display_currency) || 'USD';
|
||||
|
||||
function showSuccessToast(message) {
|
||||
try {
|
||||
const body = document.getElementById('successToastBody');
|
||||
if (!body) return;
|
||||
body.textContent = message;
|
||||
const toastEl = document.getElementById('successToast');
|
||||
if (!toastEl || !window.bootstrap || !window.bootstrap.Toast) return;
|
||||
const toast = new bootstrap.Toast(toastEl);
|
||||
toast.show();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function showErrorToast(message) {
|
||||
try {
|
||||
const body = document.getElementById('errorToastBody');
|
||||
if (!body) return;
|
||||
body.textContent = message;
|
||||
const toastEl = document.getElementById('errorToast');
|
||||
if (!toastEl || !window.bootstrap || !window.bootstrap.Toast) return;
|
||||
const toast = new bootstrap.Toast(toastEl);
|
||||
toast.show();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function formatPaymentMethod(paymentMethodId) {
|
||||
const paymentMethods = {
|
||||
credit_card: 'Credit Card',
|
||||
debit_card: 'Debit Card',
|
||||
paypal: 'PayPal',
|
||||
bank_transfer: 'Bank Transfer',
|
||||
apple_pay: 'Apple Pay',
|
||||
google_pay: 'Google Pay',
|
||||
stripe: 'Stripe',
|
||||
square: 'Square'
|
||||
};
|
||||
return paymentMethods[paymentMethodId] || paymentMethodId || '';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Input: update total cost
|
||||
const creditsAmount = document.getElementById('creditsAmount');
|
||||
if (creditsAmount) {
|
||||
creditsAmount.addEventListener('input', function () {
|
||||
const amount = parseFloat(this.value) || 0;
|
||||
const totalCost = amount.toFixed(2);
|
||||
const totalEl = document.getElementById('totalCost');
|
||||
if (totalEl) totalEl.textContent = totalCost;
|
||||
});
|
||||
}
|
||||
|
||||
// Payment method label update
|
||||
const paymentMethodSelect = document.getElementById('paymentMethod');
|
||||
if (paymentMethodSelect) {
|
||||
paymentMethodSelect.addEventListener('change', function () {
|
||||
const selectedValue = this.value;
|
||||
if (selectedValue) {
|
||||
const placeholderOption = this.querySelector('option[value=""]');
|
||||
if (placeholderOption) placeholderOption.style.display = 'none';
|
||||
updatePaymentMethodLabel(selectedValue);
|
||||
} else {
|
||||
const label = document.querySelector('label[for="paymentMethod"]');
|
||||
if (label) label.textContent = 'Payment Method';
|
||||
const placeholderOption = this.querySelector('option[value=""]');
|
||||
if (placeholderOption) placeholderOption.style.display = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// When buy credits modal opens, load last payment method
|
||||
const buyCreditsModal = document.getElementById('buyCreditsModal');
|
||||
if (buyCreditsModal) {
|
||||
buyCreditsModal.addEventListener('show.bs.modal', function () {
|
||||
loadLastPaymentMethod();
|
||||
});
|
||||
}
|
||||
|
||||
// Auto top-up toggle visibility
|
||||
const autoToggle = document.getElementById('autoTopUpEnabled');
|
||||
if (autoToggle) {
|
||||
autoToggle.addEventListener('change', function () {
|
||||
const settingsDiv = document.getElementById('autoTopUpSettings');
|
||||
if (settingsDiv) settingsDiv.style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadAutoTopUpStatus();
|
||||
});
|
||||
|
||||
// Event delegation for wallet actions
|
||||
document.addEventListener('click', function (e) {
|
||||
const el = e.target.closest('[data-action]');
|
||||
if (!el) return;
|
||||
const action = el.getAttribute('data-action');
|
||||
if (!action) return;
|
||||
|
||||
switch (action) {
|
||||
case 'refresh-wallet':
|
||||
e.preventDefault();
|
||||
refreshWalletData();
|
||||
break;
|
||||
case 'buy-credits':
|
||||
e.preventDefault();
|
||||
buyCredits();
|
||||
break;
|
||||
case 'transfer-credits':
|
||||
e.preventDefault();
|
||||
transferCredits();
|
||||
break;
|
||||
case 'save-auto-topup-settings':
|
||||
e.preventDefault();
|
||||
saveAutoTopUpSettings();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, false);
|
||||
|
||||
function updatePaymentMethodLabel(paymentMethodValue) {
|
||||
const label = document.querySelector('label[for="paymentMethod"]');
|
||||
if (label && paymentMethodValue) {
|
||||
const paymentMethodName = formatPaymentMethod(paymentMethodValue);
|
||||
label.textContent = `Payment Method: ${paymentMethodName}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function buyCredits() {
|
||||
const amount = document.getElementById('creditsAmount')?.value;
|
||||
const paymentMethod = document.getElementById('paymentMethod')?.value;
|
||||
|
||||
if (!amount || !paymentMethod) {
|
||||
showErrorToast('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiJson('/api/wallet/buy-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ amount: parseFloat(amount), payment_method: paymentMethod })
|
||||
});
|
||||
|
||||
{
|
||||
showSuccessToast('Credits purchase successful!');
|
||||
// Close modal
|
||||
const modalEl = document.getElementById('buyCreditsModal');
|
||||
if (modalEl && window.bootstrap?.Modal) {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
// Reset form
|
||||
const form = document.getElementById('buyCreditsForm');
|
||||
if (form) form.reset();
|
||||
const totalEl = document.getElementById('totalCost');
|
||||
if (totalEl) totalEl.textContent = '0.00';
|
||||
|
||||
// Update UI
|
||||
await refreshTransactionsTable();
|
||||
await refreshWalletData();
|
||||
|
||||
// Ensure server-side values are reflected (best-effort)
|
||||
// location.reload(); // optional: uncomment if required by UX
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast('Error processing purchase: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function transferCredits() {
|
||||
const toUser = document.getElementById('recipientEmail')?.value;
|
||||
const amount = document.getElementById('transferAmount')?.value;
|
||||
const note = document.getElementById('transferNote')?.value;
|
||||
|
||||
if (!toUser || !amount) {
|
||||
showErrorToast('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiJson('/api/wallet/transfer-credits', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ to_user: toUser, amount: parseFloat(amount), note: note || null })
|
||||
});
|
||||
|
||||
{
|
||||
showSuccessToast('Transfer successful!');
|
||||
// Close modal
|
||||
const modalEl = document.getElementById('transferCreditsModal');
|
||||
if (modalEl && window.bootstrap?.Modal) {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
// Reset form
|
||||
const form = document.getElementById('transferCreditsForm');
|
||||
if (form) form.reset();
|
||||
|
||||
// Update UI
|
||||
await refreshTransactionsTable();
|
||||
await refreshWalletData();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast('Error processing transfer: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshWalletData() {
|
||||
try {
|
||||
const data = await apiJson('/api/wallet/info', { cache: 'no-store' });
|
||||
|
||||
if (data && typeof data.balance === 'number') {
|
||||
const bal = Number(data.balance);
|
||||
const balEl = document.getElementById('wallet-balance');
|
||||
const usdEl = document.getElementById('usd-equivalent');
|
||||
const availEl = document.getElementById('availableBalance');
|
||||
if (balEl) balEl.textContent = `${currencySymbol}${bal.toFixed(2)}`;
|
||||
if (usdEl) usdEl.textContent = `${currencySymbol}${bal.toFixed(2)}`;
|
||||
if (availEl) availEl.textContent = bal.toFixed(2);
|
||||
}
|
||||
|
||||
await refreshTransactionsTable();
|
||||
} catch (error) {
|
||||
console.error('Error refreshing wallet data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTransactionsTable() {
|
||||
try {
|
||||
let transactions = await apiJson('/api/wallet/transactions', { cache: 'no-store' });
|
||||
|
||||
const tbody = document.getElementById('transactions-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (Array.isArray(transactions) && transactions.length > 0) {
|
||||
tbody.innerHTML = transactions.map(transaction => {
|
||||
const tt = transaction.transaction_type || {};
|
||||
const isPositive = !!(tt.CreditsPurchase || tt.Earning || tt.Unstake);
|
||||
const typeLabel = tt.CreditsPurchase ? 'Credits Purchase' :
|
||||
tt.CreditsSale ? 'Credits Sale' :
|
||||
tt.Rental ? 'Rental' :
|
||||
(tt.Purchase || tt.InstantPurchase) ? 'Purchase' :
|
||||
(tt.CreditsTransfer || tt.Transfer) ? 'Credits Transfer' :
|
||||
tt.Earning ? 'Earning' :
|
||||
tt.Exchange ? 'Exchange' :
|
||||
tt.Stake ? 'Stake' :
|
||||
tt.Unstake ? 'Unstake' : 'Unknown';
|
||||
const typeBadge = tt.CreditsPurchase ? 'bg-success' :
|
||||
tt.CreditsSale ? 'bg-danger' :
|
||||
tt.Rental ? 'bg-primary' :
|
||||
(tt.Purchase || tt.InstantPurchase) ? 'bg-danger' :
|
||||
(tt.CreditsTransfer || tt.Transfer) ? 'bg-info' :
|
||||
tt.Earning ? 'bg-success' :
|
||||
tt.Exchange ? 'bg-secondary' :
|
||||
tt.Stake ? 'bg-primary' :
|
||||
tt.Unstake ? 'bg-warning' : 'bg-light text-dark';
|
||||
const statusBadge = transaction.status === 'Completed' ? 'bg-success' :
|
||||
transaction.status === 'Pending' ? 'bg-warning' : 'bg-danger';
|
||||
|
||||
const displayDate = transaction.formatted_timestamp || new Date(transaction.timestamp).toLocaleString('en-US', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false
|
||||
});
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${displayDate}</td>
|
||||
<td><span class="badge ${typeBadge}">${typeLabel}</span></td>
|
||||
<td>
|
||||
<span class="${isPositive ? 'text-success' : 'text-danger'}">
|
||||
${isPositive ? '+' : '-'}${Math.abs(Number(transaction.amount) || 0).toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="badge ${statusBadge}">${transaction.status}</span></td>
|
||||
<td>${transaction.id}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted">No transactions yet</td></tr>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing transactions:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLastPaymentMethod() {
|
||||
try {
|
||||
const data = await apiJson('/api/wallet/last-payment-method', { cache: 'no-store' });
|
||||
|
||||
if (data && (data.success === true || data.last_payment_method)) {
|
||||
const select = document.getElementById('paymentMethod');
|
||||
if (select && data.last_payment_method) {
|
||||
select.value = data.last_payment_method;
|
||||
select.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAutoTopUpStatus() {
|
||||
try {
|
||||
const data = await apiJson('/api/wallet/auto-topup/status', { cache: 'no-store' });
|
||||
|
||||
const statusBadge = document.getElementById('autoTopUpStatus');
|
||||
const contentDiv = document.getElementById('autoTopUpContent');
|
||||
if (!statusBadge || !contentDiv) return;
|
||||
|
||||
if (data.enabled && data.settings) {
|
||||
statusBadge.textContent = 'Enabled';
|
||||
statusBadge.className = 'badge bg-success';
|
||||
contentDiv.innerHTML = `
|
||||
<div class="row">
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-success mb-1">$${data.settings.threshold_amount_usd}</div>
|
||||
<small class="text-muted">Threshold</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-primary mb-1">$${data.settings.topup_amount_usd}</div>
|
||||
<small class="text-muted">Top-Up Amount</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-info mb-1">${formatPaymentMethod(data.settings.payment_method_id)}</div>
|
||||
<small class="text-muted">Payment Method</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-muted mb-1">${(data.settings.daily_limit_usd !== null && data.settings.daily_limit_usd !== undefined) ? ('$' + data.settings.daily_limit_usd) : 'No limit'}</div>
|
||||
<small class="text-muted">Daily Limit</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-muted mb-1">${(data.settings.monthly_limit_usd !== null && data.settings.monthly_limit_usd !== undefined) ? ('$' + data.settings.monthly_limit_usd) : 'No limit'}</div>
|
||||
<small class="text-muted">Monthly Limit</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Prefill form values
|
||||
const enabledEl = document.getElementById('autoTopUpEnabled');
|
||||
const thresholdEl = document.getElementById('thresholdAmount');
|
||||
const topupEl = document.getElementById('topUpAmount');
|
||||
const pmEl = document.getElementById('autoTopUpPaymentMethod');
|
||||
const dailyEl = document.getElementById('dailyLimit');
|
||||
const monthlyEl = document.getElementById('monthlyLimit');
|
||||
const settingsDiv = document.getElementById('autoTopUpSettings');
|
||||
if (enabledEl) enabledEl.checked = true;
|
||||
if (thresholdEl) thresholdEl.value = data.settings.threshold_amount_usd;
|
||||
if (topupEl) topupEl.value = data.settings.topup_amount_usd;
|
||||
if (pmEl) pmEl.value = data.settings.payment_method_id;
|
||||
if (dailyEl) dailyEl.value = data.settings.daily_limit_usd || 0;
|
||||
if (monthlyEl) monthlyEl.value = data.settings.monthly_limit_usd || 0;
|
||||
if (settingsDiv) settingsDiv.style.display = 'block';
|
||||
} else {
|
||||
statusBadge.textContent = 'Disabled';
|
||||
statusBadge.className = 'badge bg-secondary';
|
||||
contentDiv.innerHTML = `
|
||||
<div class="text-center text-muted">
|
||||
<i class="bi bi-lightning-charge fs-1 mb-3"></i>
|
||||
<p>Auto Top-Up is currently disabled. Configure your preferences to enable it.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auto top-up status:', error);
|
||||
const statusBadge = document.getElementById('autoTopUpStatus');
|
||||
const contentDiv = document.getElementById('autoTopUpContent');
|
||||
if (statusBadge) {
|
||||
statusBadge.textContent = 'Error';
|
||||
statusBadge.className = 'badge bg-danger';
|
||||
}
|
||||
if (contentDiv) {
|
||||
contentDiv.innerHTML = `
|
||||
<div class="text-center text-danger">
|
||||
<i class="bi bi-exclamation-triangle fs-1 mb-3"></i>
|
||||
<p>Error loading auto top-up settings. Please try again.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAutoTopUpSettings() {
|
||||
const enabled = !!document.getElementById('autoTopUpEnabled')?.checked;
|
||||
|
||||
if (!enabled) {
|
||||
// Disable auto top-up
|
||||
try {
|
||||
await apiJson('/api/wallet/auto-topup/configure', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
enabled: false,
|
||||
threshold_amount: 0,
|
||||
topup_amount: 0,
|
||||
payment_method_id: '',
|
||||
daily_limit: null,
|
||||
monthly_limit: null
|
||||
}
|
||||
});
|
||||
{
|
||||
showSuccessToast('Auto Top-Up disabled successfully!');
|
||||
const modalEl = document.getElementById('configureAutoTopUpModal');
|
||||
if (modalEl && window.bootstrap?.Modal) {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
loadAutoTopUpStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast('Error updating settings: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const thresholdAmount = document.getElementById('thresholdAmount')?.value;
|
||||
const topUpAmount = document.getElementById('topUpAmount')?.value;
|
||||
const paymentMethod = document.getElementById('autoTopUpPaymentMethod')?.value;
|
||||
const dailyLimit = document.getElementById('dailyLimit')?.value;
|
||||
const monthlyLimit = document.getElementById('monthlyLimit')?.value;
|
||||
|
||||
if (!thresholdAmount || !topUpAmount || !paymentMethod) {
|
||||
showErrorToast('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (parseFloat(thresholdAmount) >= parseFloat(topUpAmount)) {
|
||||
showErrorToast('Top-up amount must be greater than threshold amount');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await apiJson('/api/wallet/auto-topup/configure', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
enabled: true,
|
||||
threshold_amount: parseFloat(thresholdAmount),
|
||||
topup_amount: parseFloat(topUpAmount),
|
||||
payment_method_id: paymentMethod,
|
||||
daily_limit: dailyLimit ? parseFloat(dailyLimit) : null,
|
||||
monthly_limit: monthlyLimit ? parseFloat(monthlyLimit) : null
|
||||
}
|
||||
});
|
||||
{
|
||||
showSuccessToast('Auto Top-Up settings saved successfully!');
|
||||
const modalEl = document.getElementById('configureAutoTopUpModal');
|
||||
if (modalEl && window.bootstrap?.Modal) {
|
||||
const modal = bootstrap.Modal.getInstance(modalEl) || new bootstrap.Modal(modalEl);
|
||||
modal.hide();
|
||||
}
|
||||
loadAutoTopUpStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast('Error saving settings: ' + (error?.message || 'Unknown error'));
|
||||
}
|
||||
}
|
||||
})();
|
364
src/static/js/demo-workflow.js
Normal file
364
src/static/js/demo-workflow.js
Normal file
@@ -0,0 +1,364 @@
|
||||
// Demo Workflow JavaScript
|
||||
// This file provides a comprehensive demo of the ThreeFold Dashboard functionality
|
||||
|
||||
class DemoWorkflow {
|
||||
constructor() {
|
||||
this.currentStep = 0;
|
||||
this.steps = [
|
||||
{
|
||||
title: "Welcome to ThreeFold Dashboard Demo",
|
||||
description: "This demo will showcase the complete interactive functionality of the ThreeFold ecosystem.",
|
||||
action: () => this.showWelcome()
|
||||
},
|
||||
{
|
||||
title: "App Provider: Register New Application",
|
||||
description: "Let's start by registering a new application as an App Provider.",
|
||||
action: () => this.demoAppRegistration()
|
||||
},
|
||||
{
|
||||
title: "Service Provider: Create New Service",
|
||||
description: "Now let's create a new service offering as a Service Provider.",
|
||||
action: () => this.demoServiceCreation()
|
||||
},
|
||||
{
|
||||
title: "Marketplace Integration",
|
||||
description: "See how your apps and services automatically appear in the marketplace.",
|
||||
action: () => this.demoMarketplaceIntegration()
|
||||
},
|
||||
{
|
||||
title: "User: Deploy Application",
|
||||
description: "As a user, let's deploy an application from the marketplace.",
|
||||
action: () => this.demoAppDeployment()
|
||||
},
|
||||
{
|
||||
title: "Farmer: Node Management",
|
||||
description: "Manage your farming nodes and monitor capacity.",
|
||||
action: () => this.demoNodeManagement()
|
||||
},
|
||||
{
|
||||
title: "Cross-Dashboard Integration",
|
||||
description: "See how actions in one dashboard affect others in real-time.",
|
||||
action: () => this.demoCrossIntegration()
|
||||
},
|
||||
{
|
||||
title: "Demo Complete",
|
||||
description: "You've seen the complete ThreeFold ecosystem in action!",
|
||||
action: () => this.showCompletion()
|
||||
}
|
||||
];
|
||||
this.initializeDemo();
|
||||
}
|
||||
|
||||
initializeDemo() {
|
||||
this.createDemoUI();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
createDemoUI() {
|
||||
// Create demo control panel
|
||||
const demoPanel = document.createElement('div');
|
||||
demoPanel.id = 'demo-panel';
|
||||
demoPanel.className = 'demo-panel';
|
||||
demoPanel.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
background: white;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
z-index: 10000;
|
||||
max-width: 400px;
|
||||
font-family: 'Poppins', sans-serif;
|
||||
`;
|
||||
|
||||
demoPanel.innerHTML = `
|
||||
<div class="demo-header">
|
||||
<h5 class="mb-2">🚀 ThreeFold Demo</h5>
|
||||
<div class="progress mb-3" style="height: 6px;">
|
||||
<div class="progress-bar bg-primary" id="demo-progress" role="progressbar" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="demo-content">
|
||||
<h6 id="demo-title">Welcome to ThreeFold Dashboard Demo</h6>
|
||||
<p id="demo-description" class="text-muted small">This demo will showcase the complete interactive functionality of the ThreeFold ecosystem.</p>
|
||||
</div>
|
||||
<div class="demo-controls mt-3">
|
||||
<button class="btn btn-primary btn-sm me-2" id="demo-next">Start Demo</button>
|
||||
<button class="btn btn-outline-secondary btn-sm me-2" id="demo-prev" disabled>Previous</button>
|
||||
<button class="btn btn-outline-danger btn-sm" id="demo-close">Close</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(demoPanel);
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
document.getElementById('demo-next').addEventListener('click', () => this.nextStep());
|
||||
document.getElementById('demo-prev').addEventListener('click', () => this.prevStep());
|
||||
document.getElementById('demo-close').addEventListener('click', () => this.closeDemo());
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.currentStep < this.steps.length - 1) {
|
||||
this.currentStep++;
|
||||
this.executeStep();
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (this.currentStep > 0) {
|
||||
this.currentStep--;
|
||||
this.executeStep();
|
||||
}
|
||||
}
|
||||
|
||||
executeStep() {
|
||||
const step = this.steps[this.currentStep];
|
||||
|
||||
// Update UI
|
||||
document.getElementById('demo-title').textContent = step.title;
|
||||
document.getElementById('demo-description').textContent = step.description;
|
||||
|
||||
// Update progress
|
||||
const progress = ((this.currentStep + 1) / this.steps.length) * 100;
|
||||
document.getElementById('demo-progress').style.width = `${progress}%`;
|
||||
|
||||
// Update buttons
|
||||
document.getElementById('demo-prev').disabled = this.currentStep === 0;
|
||||
const nextBtn = document.getElementById('demo-next');
|
||||
if (this.currentStep === this.steps.length - 1) {
|
||||
nextBtn.textContent = 'Finish';
|
||||
} else {
|
||||
nextBtn.textContent = 'Next';
|
||||
}
|
||||
|
||||
// Execute step action
|
||||
step.action();
|
||||
}
|
||||
|
||||
showWelcome() {
|
||||
showNotification('Welcome to the ThreeFold Dashboard Demo! 🎉', 'info');
|
||||
}
|
||||
|
||||
demoAppRegistration() {
|
||||
showNotification('Demo: Navigating to App Provider dashboard...', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname !== '/dashboard/app-provider') {
|
||||
showNotification('Please navigate to the App Provider dashboard to continue the demo', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate clicking the register app button
|
||||
const registerBtn = document.querySelector('[data-bs-target="#registerAppModal"]');
|
||||
if (registerBtn) {
|
||||
registerBtn.click();
|
||||
|
||||
setTimeout(() => {
|
||||
// Fill in demo data
|
||||
this.fillAppRegistrationForm();
|
||||
}, 500);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
fillAppRegistrationForm() {
|
||||
const formData = {
|
||||
appName: 'Demo Secure Chat App',
|
||||
appDesc: 'A decentralized, end-to-end encrypted chat application built for the ThreeFold Grid',
|
||||
appCategory: 'communication',
|
||||
appType: 'container',
|
||||
appRepo: 'https://github.com/demo/secure-chat',
|
||||
minCPU: '2',
|
||||
minRAM: '4',
|
||||
minStorage: '10',
|
||||
pricingType: 'subscription',
|
||||
priceAmount: '15'
|
||||
};
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
element.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
// Check self-healing
|
||||
const selfHealingCheckbox = document.getElementById('selfHealing');
|
||||
if (selfHealingCheckbox) {
|
||||
selfHealingCheckbox.checked = true;
|
||||
}
|
||||
|
||||
showNotification('Demo form filled! Click "Register Application" to continue.', 'success');
|
||||
}
|
||||
|
||||
demoServiceCreation() {
|
||||
showNotification('Demo: Navigating to Service Provider dashboard...', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
if (window.location.pathname !== '/dashboard/service-provider') {
|
||||
showNotification('Please navigate to the Service Provider dashboard to continue the demo', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simulate clicking the create service button
|
||||
const createBtn = document.querySelector('[data-bs-target="#createServiceModal"]');
|
||||
if (createBtn) {
|
||||
createBtn.click();
|
||||
|
||||
setTimeout(() => {
|
||||
// Fill in demo data
|
||||
this.fillServiceCreationForm();
|
||||
}, 500);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
fillServiceCreationForm() {
|
||||
const formData = {
|
||||
serviceName: 'Demo ThreeFold Migration Service',
|
||||
serviceDesc: 'Professional migration service to help businesses move their workloads to the ThreeFold Grid with zero downtime',
|
||||
serviceCategory: 'migration',
|
||||
serviceDelivery: 'hybrid',
|
||||
pricingType: 'hourly',
|
||||
priceAmount: '85',
|
||||
serviceExperience: 'expert',
|
||||
availableHours: '30',
|
||||
responseTime: '4',
|
||||
serviceSkills: 'Docker, Kubernetes, ThreeFold Grid, Cloud Migration, DevOps'
|
||||
};
|
||||
|
||||
Object.entries(formData).forEach(([key, value]) => {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
element.value = value;
|
||||
element.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
|
||||
showNotification('Demo form filled! Click "Create Service" to continue.', 'success');
|
||||
}
|
||||
|
||||
demoMarketplaceIntegration() {
|
||||
showNotification('Demo: Your apps and services are now available in the marketplace!', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
showNotification('Navigate to /marketplace/applications or /marketplace/services to see your listings', 'info');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
demoAppDeployment() {
|
||||
showNotification('Demo: Simulating app deployment from marketplace...', 'info');
|
||||
|
||||
// Simulate marketplace purchase
|
||||
const purchaseEvent = new CustomEvent('marketplacePurchase', {
|
||||
detail: {
|
||||
name: 'Demo Secure Chat App',
|
||||
provider_name: 'Demo Provider',
|
||||
price: 15
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(purchaseEvent);
|
||||
}
|
||||
|
||||
demoNodeManagement() {
|
||||
showNotification('Demo: Simulating farmer node management...', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate node status change
|
||||
const nodeEvent = new CustomEvent('nodeStatusChange', {
|
||||
detail: {
|
||||
node: { id: 'TF-DEMO-001' },
|
||||
oldStatus: 'Online',
|
||||
newStatus: 'Maintenance'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(nodeEvent);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
demoCrossIntegration() {
|
||||
showNotification('Demo: Showing cross-dashboard integration...', 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate deployment status change
|
||||
const deploymentEvent = new CustomEvent('deploymentStatusChange', {
|
||||
detail: {
|
||||
deployment: { app_name: 'Demo Secure Chat App' },
|
||||
oldStatus: 'Deploying',
|
||||
newStatus: 'Active'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(deploymentEvent);
|
||||
}, 1000);
|
||||
|
||||
setTimeout(() => {
|
||||
// Simulate new client request
|
||||
const clientEvent = new CustomEvent('newClientRequest', {
|
||||
detail: {
|
||||
client_name: 'Demo Client Corp'
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(clientEvent);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
showCompletion() {
|
||||
showNotification('🎉 Demo completed! You\'ve experienced the full ThreeFold ecosystem.', 'success');
|
||||
|
||||
setTimeout(() => {
|
||||
this.closeDemo();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
closeDemo() {
|
||||
const demoPanel = document.getElementById('demo-panel');
|
||||
if (demoPanel) {
|
||||
demoPanel.remove();
|
||||
}
|
||||
showNotification('Demo closed. Explore the dashboards on your own!', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-start demo if URL parameter is present
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('demo') === 'true') {
|
||||
setTimeout(() => {
|
||||
new DemoWorkflow();
|
||||
}, 2000); // Wait for page to fully load
|
||||
}
|
||||
});
|
||||
|
||||
// Add demo starter button to all dashboard pages (currently hidden)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Demo button is temporarily hidden
|
||||
// Uncomment the code below to re-enable the Start Demo button
|
||||
/*
|
||||
if (window.location.pathname.includes('/dashboard/')) {
|
||||
const demoButton = document.createElement('button');
|
||||
demoButton.className = 'btn btn-outline-primary btn-sm demo-starter';
|
||||
demoButton.innerHTML = '🚀 Start Demo';
|
||||
demoButton.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9998;
|
||||
`;
|
||||
|
||||
demoButton.addEventListener('click', () => {
|
||||
new DemoWorkflow();
|
||||
});
|
||||
|
||||
document.body.appendChild(demoButton);
|
||||
}
|
||||
*/
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = DemoWorkflow;
|
||||
}
|
40
src/static/js/marketplace-category.js
Normal file
40
src/static/js/marketplace-category.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Marketplace category pages functionality (applications, gateways, three_nodes)
|
||||
* Migrated from inline scripts to use apiJson and shared error handlers
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add to cart functionality for all category pages
|
||||
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const productId = this.dataset.productId;
|
||||
|
||||
if (!productId) {
|
||||
showErrorToast('Product ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(this, 'Adding...');
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: 1
|
||||
})
|
||||
});
|
||||
|
||||
setButtonSuccess(this, 'Added!');
|
||||
showSuccessToast('Item added to cart');
|
||||
|
||||
// Update cart count in navbar
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
window.updateCartCount();
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, 'adding to cart', this);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
89
src/static/js/marketplace-compute.js
Normal file
89
src/static/js/marketplace-compute.js
Normal file
@@ -0,0 +1,89 @@
|
||||
(function(){
|
||||
'use strict';
|
||||
function showAuthenticationModal(message){
|
||||
const html=`<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true"><div class="modal-dialog modal-dialog-centered"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="authModalLabel"><i class="bi bi-lock me-2"></i>Authentication Required</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div><div class="modal-body text-center"><div class="mb-3"><i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i></div><p class="mb-3">${message}</p><div class="d-grid gap-2 d-md-flex justify-content-md-center"><a href="/login" class="btn btn-primary me-md-2"><i class="bi bi-box-arrow-in-right me-2"></i>Log In</a><a href="/register" class="btn btn-outline-primary"><i class="bi bi-person-plus me-2"></i>Register</a></div></div></div></div></div>`;
|
||||
document.getElementById('authModal')?.remove();
|
||||
document.body.insertAdjacentHTML('beforeend', html);
|
||||
new bootstrap.Modal(document.getElementById('authModal')).show();
|
||||
document.getElementById('authModal').addEventListener('hidden.bs.modal', function(){ this.remove(); });
|
||||
}
|
||||
function formatLocationDisplays(){
|
||||
document.querySelectorAll('.node-location').forEach(el=>{
|
||||
const loc=el.getAttribute('data-location');
|
||||
if(!loc) return; const parts=loc.split(',').map(s=>s.trim());
|
||||
el.textContent=(parts.length>=2 && parts[0]==='Unknown')?parts[1]:loc;
|
||||
});
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
formatLocationDisplays();
|
||||
document.querySelectorAll('.rent-product-btn').forEach(btn=>{
|
||||
btn.addEventListener('click', async function(){
|
||||
const id=this.dataset.productId, name=this.dataset.productName, price=this.dataset.price;
|
||||
if(!confirm(`Rent "${name}" for $${price} per month?`)) return;
|
||||
const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Processing...'; this.disabled=true;
|
||||
try{
|
||||
await window.apiJson(`/api/products/${id}/rent`, {
|
||||
method:'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product_id:id, duration:'monthly' })
|
||||
});
|
||||
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Rented!';
|
||||
this.classList.replace('btn-success','btn-info');
|
||||
if (typeof window.showToast === 'function') { window.showToast(`Successfully rented "${name}"!`, 'success'); }
|
||||
else { alert(`Successfully rented "${name}"!`); }
|
||||
setTimeout(()=>{window.location.href='/dashboard';},1000);
|
||||
}catch(e){
|
||||
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
|
||||
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
this.classList.replace('btn-success','btn-danger');
|
||||
if (typeof window.showToast === 'function') { window.showToast(`Rental failed: ${e.message || 'Unknown error'}`, 'error'); }
|
||||
else { alert(`Rental failed: ${e.message || 'Unknown error'}`); }
|
||||
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-success'); this.disabled=false;},3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.buy-product-btn').forEach(btn=>{
|
||||
btn.addEventListener('click', async function(){
|
||||
const id=this.dataset.productId, name=this.dataset.productName, price=this.dataset.price;
|
||||
if(!confirm(`Purchase "${name}" for $${price}?`)) return;
|
||||
const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Processing...'; this.disabled=true;
|
||||
try{
|
||||
await window.apiJson(`/api/products/${id}/purchase`, { method:'POST' });
|
||||
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Purchased!';
|
||||
this.classList.replace('btn-primary','btn-info');
|
||||
if (typeof window.showToast === 'function') { window.showToast(`Successfully purchased "${name}"!`, 'success'); }
|
||||
else { alert(`Successfully purchased "${name}"!`); }
|
||||
setTimeout(()=>{window.location.href='/dashboard';},1000);
|
||||
}catch(e){
|
||||
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
|
||||
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
this.classList.replace('btn-primary','btn-danger');
|
||||
if (typeof window.showToast === 'function') { window.showToast(`Purchase failed: ${e.message || 'Unknown error'}`, 'error'); }
|
||||
else { alert(`Purchase failed: ${e.message || 'Unknown error'}`); }
|
||||
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-primary'); this.disabled=false;},3000);
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.add-to-cart-btn').forEach(btn=>{
|
||||
btn.addEventListener('click', function(){
|
||||
const id=this.dataset.productId; const orig=this.innerHTML; this.innerHTML='<i class="bi bi-hourglass-split me-1"></i>Adding...'; this.disabled=true;
|
||||
window.apiJson('/api/cart/add', { method:'POST', body:{ product_id:id, quantity:1 } })
|
||||
.then(()=>{
|
||||
this.innerHTML='<i class="bi bi-check-circle me-1"></i>Added!';
|
||||
this.classList.replace('btn-primary','btn-success');
|
||||
try { if (typeof window.updateCartCount === 'function') window.updateCartCount(); } catch(_){}
|
||||
try { if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated(); } catch(_){}
|
||||
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-success','btn-primary'); this.disabled=false;},2000);
|
||||
})
|
||||
.catch(e=>{
|
||||
if (e && e.status === 401){ showAuthenticationModal(e.message || 'Make sure to register or log in to continue'); this.innerHTML=orig; this.disabled=false; return; }
|
||||
if (e && e.status === 402){ this.innerHTML=orig; this.disabled=false; return; }
|
||||
console.error('Error adding to cart:',e);
|
||||
this.innerHTML='<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
this.classList.replace('btn-primary','btn-danger');
|
||||
setTimeout(()=>{ this.innerHTML=orig; this.classList.replace('btn-danger','btn-primary'); this.disabled=false;},2000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
565
src/static/js/marketplace-integration.js
Normal file
565
src/static/js/marketplace-integration.js
Normal file
@@ -0,0 +1,565 @@
|
||||
// Marketplace Integration JavaScript
|
||||
// This file handles the integration between dashboards and marketplace functionality
|
||||
|
||||
class MarketplaceIntegration {
|
||||
constructor() {
|
||||
this.initializeIntegration();
|
||||
}
|
||||
|
||||
initializeIntegration() {
|
||||
// Initialize marketplace integration features
|
||||
this.setupAppProviderIntegration();
|
||||
this.setupServiceProviderIntegration();
|
||||
this.setupUserIntegration();
|
||||
this.setupNotificationSystem();
|
||||
}
|
||||
|
||||
// App Provider Integration
|
||||
setupAppProviderIntegration() {
|
||||
// Sync published apps to marketplace
|
||||
this.syncAppsToMarketplace();
|
||||
|
||||
// Handle app publishing workflow
|
||||
this.setupAppPublishingWorkflow();
|
||||
}
|
||||
|
||||
syncAppsToMarketplace() {
|
||||
// Get apps from session storage (simulated persistence)
|
||||
const userApps = JSON.parse(sessionStorage.getItem('userApps') || '[]');
|
||||
const marketplaceApps = JSON.parse(sessionStorage.getItem('marketplaceApps') || '[]');
|
||||
|
||||
// Sync new apps to marketplace
|
||||
userApps.forEach(app => {
|
||||
const existingApp = marketplaceApps.find(mApp => mApp.source_app_id === app.id);
|
||||
if (!existingApp && app.status === 'Active') {
|
||||
const marketplaceApp = this.convertAppToMarketplaceFormat(app);
|
||||
marketplaceApps.push(marketplaceApp);
|
||||
}
|
||||
});
|
||||
|
||||
sessionStorage.setItem('marketplaceApps', JSON.stringify(marketplaceApps));
|
||||
}
|
||||
|
||||
convertAppToMarketplaceFormat(app) {
|
||||
const currentUser = userDB.getCurrentUser();
|
||||
return {
|
||||
id: 'mp-' + app.id,
|
||||
source_app_id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
category: app.category,
|
||||
provider_id: currentUser.id,
|
||||
provider_name: currentUser.display_name,
|
||||
provider_username: currentUser.username,
|
||||
price: app.monthly_revenue || 50,
|
||||
rating: app.rating || 0,
|
||||
deployments: app.deployments || 0,
|
||||
status: 'Available',
|
||||
created_at: app.created_at || new Date().toISOString(),
|
||||
featured: false,
|
||||
tags: [app.category, 'self-healing', 'sovereign'],
|
||||
attributes: {
|
||||
cpu_cores: { value: 2, unit: 'cores' },
|
||||
memory_gb: { value: 4, unit: 'GB' },
|
||||
storage_gb: { value: 20, unit: 'GB' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setupAppPublishingWorkflow() {
|
||||
// Listen for app registration events
|
||||
document.addEventListener('appRegistered', (event) => {
|
||||
const app = event.detail;
|
||||
this.publishAppToMarketplace(app);
|
||||
});
|
||||
}
|
||||
|
||||
publishAppToMarketplace(app) {
|
||||
showNotification(`Publishing ${app.name} to marketplace...`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Add to marketplace
|
||||
const marketplaceApps = JSON.parse(sessionStorage.getItem('marketplaceApps') || '[]');
|
||||
const marketplaceApp = this.convertAppToMarketplaceFormat(app);
|
||||
marketplaceApps.push(marketplaceApp);
|
||||
sessionStorage.setItem('marketplaceApps', JSON.stringify(marketplaceApps));
|
||||
|
||||
showNotification(`${app.name} is now available in the marketplace!`, 'success');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Service Provider Integration
|
||||
setupServiceProviderIntegration() {
|
||||
// Sync services to marketplace
|
||||
this.syncServicesToMarketplace();
|
||||
|
||||
// Handle service publishing workflow
|
||||
this.setupServicePublishingWorkflow();
|
||||
}
|
||||
|
||||
async syncServicesToMarketplace() {
|
||||
// Get services from API and existing marketplace services
|
||||
let userServices = [];
|
||||
let marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
|
||||
|
||||
// Fetch user services from API
|
||||
try {
|
||||
const data = await window.apiJson('/api/dashboard/services', { cache: 'no-store' });
|
||||
if (data && Array.isArray(data.services)) {
|
||||
userServices = data.services;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching user services:', error);
|
||||
userServices = []; // Fallback to empty array
|
||||
}
|
||||
|
||||
// Sync new services to marketplace
|
||||
userServices.forEach(service => {
|
||||
const existingService = marketplaceServices.find(mService => mService.source_service_id === service.id);
|
||||
if (!existingService && service.status === 'Active') {
|
||||
const marketplaceService = this.convertServiceToMarketplaceFormat(service);
|
||||
marketplaceServices.push(marketplaceService);
|
||||
}
|
||||
});
|
||||
|
||||
sessionStorage.setItem('marketplaceServices', JSON.stringify(marketplaceServices));
|
||||
}
|
||||
|
||||
convertServiceToMarketplaceFormat(service) {
|
||||
const currentUser = userDB.getCurrentUser();
|
||||
return {
|
||||
id: 'ms-' + service.id,
|
||||
source_service_id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
category: service.category,
|
||||
provider_id: currentUser.id,
|
||||
provider_name: currentUser.display_name,
|
||||
provider_username: currentUser.username,
|
||||
price_per_hour: service.price_per_hour || 50,
|
||||
rating: service.rating || 0,
|
||||
clients: service.clients || 0,
|
||||
status: 'Available',
|
||||
created_at: service.created_at || new Date().toISOString(),
|
||||
featured: false,
|
||||
tags: [service.category, 'professional', 'threefold'],
|
||||
delivery_method: 'remote',
|
||||
response_time: '24 hours'
|
||||
};
|
||||
}
|
||||
|
||||
setupServicePublishingWorkflow() {
|
||||
// Listen for service creation events
|
||||
document.addEventListener('serviceCreated', (event) => {
|
||||
const service = event.detail;
|
||||
this.publishServiceToMarketplace(service);
|
||||
});
|
||||
}
|
||||
|
||||
publishServiceToMarketplace(service) {
|
||||
showNotification(`Publishing ${service.name} to marketplace...`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Add to marketplace
|
||||
const marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
|
||||
const marketplaceService = this.convertServiceToMarketplaceFormat(service);
|
||||
marketplaceServices.push(marketplaceService);
|
||||
sessionStorage.setItem('marketplaceServices', JSON.stringify(marketplaceServices));
|
||||
|
||||
showNotification(`${service.name} is now available in the marketplace!`, 'success');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// Enhanced service conversion for marketplace
|
||||
convertServiceToMarketplaceFormat(service) {
|
||||
return {
|
||||
id: `marketplace-service-${service.id}`,
|
||||
source_service_id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
category: service.category || 'Professional Services',
|
||||
provider_name: 'Service Provider', // This would come from user data in real implementation
|
||||
price_per_hour: service.price_per_hour || 0,
|
||||
pricing_type: service.pricing_type || 'hourly',
|
||||
experience_level: service.experience_level || 'intermediate',
|
||||
response_time: service.response_time || 24,
|
||||
skills: service.skills || [],
|
||||
rating: service.rating || 0,
|
||||
status: service.status || 'Active',
|
||||
availability: service.status === 'Active' ? 'Available' : 'Unavailable',
|
||||
created_at: service.created_at || new Date().toISOString(),
|
||||
featured: false,
|
||||
metadata: {
|
||||
location: 'Remote',
|
||||
rating: service.rating || 0,
|
||||
review_count: 0,
|
||||
tags: service.skills || []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// User Integration
|
||||
setupUserIntegration() {
|
||||
// Handle marketplace purchases
|
||||
this.setupPurchaseWorkflow();
|
||||
|
||||
// Handle deployment tracking
|
||||
this.setupDeploymentTracking();
|
||||
|
||||
// Listen for new service creation
|
||||
this.setupServiceCreationListener();
|
||||
}
|
||||
|
||||
setupServiceCreationListener() {
|
||||
// Listen for service creation events from service provider dashboard
|
||||
document.addEventListener('serviceCreated', (event) => {
|
||||
const service = event.detail;
|
||||
console.log('New service created:', service);
|
||||
|
||||
// Automatically publish to marketplace
|
||||
setTimeout(() => {
|
||||
this.publishServiceToMarketplace(service);
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
setupPurchaseWorkflow() {
|
||||
// Listen for marketplace purchases
|
||||
document.addEventListener('marketplacePurchase', (event) => {
|
||||
const purchase = event.detail;
|
||||
this.handleMarketplacePurchase(purchase);
|
||||
});
|
||||
}
|
||||
|
||||
handleMarketplacePurchase(purchase) {
|
||||
showNotification(`Processing purchase of ${purchase.name}...`, 'info');
|
||||
|
||||
setTimeout(() => {
|
||||
// Add to user's deployments
|
||||
const userDeployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
|
||||
const deployment = {
|
||||
id: 'dep-' + Date.now(),
|
||||
app_name: purchase.name,
|
||||
status: 'Deploying',
|
||||
deployed_at: new Date().toISOString(),
|
||||
provider: purchase.provider_name,
|
||||
cost: purchase.price,
|
||||
region: 'Auto-selected'
|
||||
};
|
||||
|
||||
userDeployments.push(deployment);
|
||||
sessionStorage.setItem('userDeployments', JSON.stringify(userDeployments));
|
||||
|
||||
showNotification(`${purchase.name} deployment started!`, 'success');
|
||||
|
||||
// Simulate deployment completion
|
||||
setTimeout(() => {
|
||||
deployment.status = 'Active';
|
||||
sessionStorage.setItem('userDeployments', JSON.stringify(userDeployments));
|
||||
showNotification(`${purchase.name} is now active!`, 'success');
|
||||
}, 5000);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
setupDeploymentTracking() {
|
||||
// Track deployment status changes
|
||||
this.monitorDeployments();
|
||||
}
|
||||
|
||||
monitorDeployments() {
|
||||
// Simulate real-time deployment monitoring
|
||||
setInterval(() => {
|
||||
const deployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
|
||||
deployments.forEach(deployment => {
|
||||
if (deployment.status === 'Deploying') {
|
||||
// Simulate deployment progress
|
||||
const random = Math.random();
|
||||
if (random > 0.8) {
|
||||
deployment.status = 'Active';
|
||||
sessionStorage.setItem('userDeployments', JSON.stringify(deployments));
|
||||
showNotification(`${deployment.app_name} is now active!`, 'success');
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 10000); // Check every 10 seconds
|
||||
}
|
||||
|
||||
// Cross-Dashboard Data Sharing
|
||||
shareDataBetweenDashboards() {
|
||||
// Share app provider data with user dashboard
|
||||
const userApps = JSON.parse(sessionStorage.getItem('userApps') || '[]');
|
||||
const userDeployments = JSON.parse(sessionStorage.getItem('userDeployments') || '[]');
|
||||
|
||||
// Update deployment counts for apps
|
||||
userApps.forEach(app => {
|
||||
const appDeployments = userDeployments.filter(dep => dep.app_name === app.name);
|
||||
app.deployments = appDeployments.length;
|
||||
});
|
||||
|
||||
sessionStorage.setItem('userApps', JSON.stringify(userApps));
|
||||
}
|
||||
|
||||
// Notification System Integration
|
||||
setupNotificationSystem() {
|
||||
// Setup cross-dashboard notifications
|
||||
this.setupCrossDashboardNotifications();
|
||||
}
|
||||
|
||||
setupCrossDashboardNotifications() {
|
||||
// Listen for various events and show notifications
|
||||
document.addEventListener('deploymentStatusChange', (event) => {
|
||||
const { deployment, oldStatus, newStatus } = event.detail;
|
||||
showNotification(`${deployment.app_name} status changed from ${oldStatus} to ${newStatus}`, 'info');
|
||||
});
|
||||
|
||||
document.addEventListener('newClientRequest', (event) => {
|
||||
const request = event.detail;
|
||||
showNotification(`New service request from ${request.client_name}`, 'info');
|
||||
});
|
||||
|
||||
document.addEventListener('nodeStatusChange', (event) => {
|
||||
const { node, oldStatus, newStatus } = event.detail;
|
||||
const statusType = newStatus === 'Online' ? 'success' :
|
||||
newStatus === 'Offline' ? 'error' : 'warning';
|
||||
showNotification(`Node ${node.id} is now ${newStatus}`, statusType);
|
||||
});
|
||||
}
|
||||
|
||||
// Utility Functions
|
||||
generateMockData() {
|
||||
// Generate mock marketplace data if none exists
|
||||
if (!sessionStorage.getItem('marketplaceApps')) {
|
||||
// Get real users from user database
|
||||
const sarahUser = userDB.getUser('user-002'); // Sarah Chen - App Provider
|
||||
|
||||
const mockApps = [
|
||||
{
|
||||
id: 'mp-mock-1',
|
||||
source_app_id: 'app-mock-1',
|
||||
name: 'Secure File Storage',
|
||||
description: 'Decentralized file storage with end-to-end encryption',
|
||||
category: 'Storage',
|
||||
provider_id: sarahUser.id,
|
||||
provider_name: sarahUser.display_name,
|
||||
provider_username: sarahUser.username,
|
||||
price: 25,
|
||||
rating: 4.5,
|
||||
deployments: 150,
|
||||
status: 'Available',
|
||||
featured: true,
|
||||
tags: ['storage', 'encryption', 'decentralized'],
|
||||
created_at: '2024-03-01T10:00:00Z',
|
||||
attributes: {
|
||||
cpu_cores: { value: 2, unit: 'cores' },
|
||||
memory_gb: { value: 4, unit: 'GB' },
|
||||
storage_gb: { value: 20, unit: 'GB' }
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mp-mock-2',
|
||||
source_app_id: 'app-mock-2',
|
||||
name: 'Team Collaboration Hub',
|
||||
description: 'Self-hosted team collaboration platform',
|
||||
category: 'Productivity',
|
||||
provider_id: sarahUser.id,
|
||||
provider_name: sarahUser.display_name,
|
||||
provider_username: sarahUser.username,
|
||||
price: 40,
|
||||
rating: 4.8,
|
||||
deployments: 89,
|
||||
status: 'Available',
|
||||
featured: false,
|
||||
tags: ['collaboration', 'productivity', 'team'],
|
||||
created_at: '2024-03-15T14:30:00Z',
|
||||
attributes: {
|
||||
cpu_cores: { value: 4, unit: 'cores' },
|
||||
memory_gb: { value: 8, unit: 'GB' },
|
||||
storage_gb: { value: 50, unit: 'GB' }
|
||||
}
|
||||
}
|
||||
];
|
||||
sessionStorage.setItem('marketplaceApps', JSON.stringify(mockApps));
|
||||
}
|
||||
|
||||
if (!sessionStorage.getItem('marketplaceServices')) {
|
||||
// Get real users from user database
|
||||
const mikeUser = userDB.getUser('user-003'); // Mike Rodriguez - Service Provider
|
||||
const emmaUser = userDB.getUser('user-004'); // Emma Wilson - Service Provider
|
||||
|
||||
const mockServices = [
|
||||
{
|
||||
id: 'ms-mock-1',
|
||||
source_service_id: 'service-mock-1',
|
||||
name: 'ThreeFold Migration Service',
|
||||
description: 'Professional migration from cloud providers to ThreeFold Grid',
|
||||
category: 'Migration',
|
||||
provider_id: mikeUser.id,
|
||||
provider_name: mikeUser.display_name,
|
||||
provider_username: mikeUser.username,
|
||||
price_per_hour: 75,
|
||||
rating: 4.9,
|
||||
clients: 25,
|
||||
status: 'Available',
|
||||
featured: true,
|
||||
tags: ['migration', 'cloud', 'professional'],
|
||||
created_at: '2024-02-15T09:00:00Z',
|
||||
delivery_method: 'remote',
|
||||
response_time: '24 hours'
|
||||
},
|
||||
{
|
||||
id: 'ms-mock-2',
|
||||
source_service_id: 'service-mock-2',
|
||||
name: 'Security Audit & Hardening',
|
||||
description: 'Comprehensive security assessment and hardening services',
|
||||
category: 'Security',
|
||||
provider_id: emmaUser.id,
|
||||
provider_name: emmaUser.display_name,
|
||||
provider_username: emmaUser.username,
|
||||
price_per_hour: 100,
|
||||
rating: 4.7,
|
||||
clients: 18,
|
||||
status: 'Available',
|
||||
featured: false,
|
||||
tags: ['security', 'audit', 'hardening'],
|
||||
created_at: '2024-03-20T11:30:00Z',
|
||||
delivery_method: 'remote',
|
||||
response_time: '12 hours'
|
||||
}
|
||||
];
|
||||
sessionStorage.setItem('marketplaceServices', JSON.stringify(mockServices));
|
||||
}
|
||||
|
||||
if (!sessionStorage.getItem('marketplaceSlices')) {
|
||||
// Get real users from user database
|
||||
const alexUser = userDB.getUser('user-001'); // Alex Thompson - Farmer
|
||||
|
||||
const mockSlices = [
|
||||
{
|
||||
id: 'slice-mock-1',
|
||||
name: 'Basic Slice',
|
||||
provider_id: alexUser.id,
|
||||
provider: alexUser.display_name,
|
||||
provider_username: alexUser.username,
|
||||
nodeId: 'TF-1001',
|
||||
resources: {
|
||||
cpu_cores: 2,
|
||||
memory_gb: 4,
|
||||
storage_gb: 100
|
||||
},
|
||||
location: alexUser.location,
|
||||
price_per_hour: 0.1,
|
||||
price_per_month: 50,
|
||||
uptime_sla: 99.5,
|
||||
certified: false,
|
||||
available: true,
|
||||
created_at: new Date(Date.now() - 86400000).toISOString() // 1 day ago
|
||||
},
|
||||
{
|
||||
id: 'slice-mock-2',
|
||||
name: 'Standard Slice',
|
||||
provider_id: alexUser.id,
|
||||
provider: alexUser.display_name,
|
||||
provider_username: alexUser.username,
|
||||
nodeId: 'TF-1002',
|
||||
resources: {
|
||||
cpu_cores: 4,
|
||||
memory_gb: 8,
|
||||
storage_gb: 250
|
||||
},
|
||||
location: alexUser.location,
|
||||
price_per_hour: 0.2,
|
||||
price_per_month: 100,
|
||||
uptime_sla: 99.8,
|
||||
certified: true,
|
||||
available: true,
|
||||
created_at: new Date(Date.now() - 172800000).toISOString() // 2 days ago
|
||||
},
|
||||
{
|
||||
id: 'slice-mock-3',
|
||||
name: 'Performance Slice',
|
||||
provider_id: alexUser.id,
|
||||
provider: alexUser.display_name,
|
||||
provider_username: alexUser.username,
|
||||
nodeId: 'TF-1003',
|
||||
resources: {
|
||||
cpu_cores: 8,
|
||||
memory_gb: 16,
|
||||
storage_gb: 500
|
||||
},
|
||||
location: alexUser.location,
|
||||
price_per_hour: 0.4,
|
||||
price_per_month: 175,
|
||||
uptime_sla: 99.9,
|
||||
certified: true,
|
||||
available: true,
|
||||
created_at: new Date(Date.now() - 259200000).toISOString() // 3 days ago
|
||||
}
|
||||
];
|
||||
sessionStorage.setItem('marketplaceSlices', JSON.stringify(mockSlices));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global notification function (shared across all dashboards)
|
||||
function showNotification(message, type = 'info') {
|
||||
// Remove existing notifications
|
||||
const existingNotifications = document.querySelectorAll('.dashboard-notification');
|
||||
existingNotifications.forEach(notification => notification.remove());
|
||||
|
||||
// Create notification element
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${getBootstrapAlertClass(type)} alert-dismissible fade show dashboard-notification`;
|
||||
notification.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
min-width: 300px;
|
||||
max-width: 500px;
|
||||
`;
|
||||
|
||||
notification.innerHTML = `
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
`;
|
||||
|
||||
// Add to page
|
||||
document.body.appendChild(notification);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) {
|
||||
notification.remove();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function getBootstrapAlertClass(type) {
|
||||
const typeMap = {
|
||||
'success': 'success',
|
||||
'error': 'danger',
|
||||
'warning': 'warning',
|
||||
'info': 'info'
|
||||
};
|
||||
return typeMap[type] || 'info';
|
||||
}
|
||||
|
||||
// Initialize marketplace integration when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only initialize if we're on a dashboard page
|
||||
if (window.location.pathname.includes('/dashboard/')) {
|
||||
const integration = new MarketplaceIntegration();
|
||||
integration.generateMockData();
|
||||
|
||||
// Share data between dashboards
|
||||
integration.shareDataBetweenDashboards();
|
||||
|
||||
// Make integration available globally
|
||||
window.marketplaceIntegration = integration;
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = MarketplaceIntegration;
|
||||
}
|
118
src/static/js/marketplace_dashboard.js
Normal file
118
src/static/js/marketplace_dashboard.js
Normal file
@@ -0,0 +1,118 @@
|
||||
// marketplace_dashboard.js
|
||||
// Externalized logic for Marketplace Overview page (CSP-compliant)
|
||||
|
||||
(function () {
|
||||
function onReady(fn) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', fn);
|
||||
} else {
|
||||
fn();
|
||||
}
|
||||
}
|
||||
|
||||
onReady(function () {
|
||||
// Event delegation for Add to Cart buttons
|
||||
document.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('.add-to-cart-btn');
|
||||
if (!btn) return;
|
||||
|
||||
const productId = btn.getAttribute('data-product-id');
|
||||
const productName = btn.getAttribute('data-product-name') || 'Item';
|
||||
const productPrice = btn.getAttribute('data-product-price') || '';
|
||||
|
||||
if (!productId) {
|
||||
console.warn('Missing data-product-id on .add-to-cart-btn');
|
||||
return;
|
||||
}
|
||||
|
||||
addToCart(productId, productName, productPrice, btn);
|
||||
});
|
||||
});
|
||||
|
||||
async function addToCart(productId, productName, productPrice, buttonElement) {
|
||||
const originalText = buttonElement.innerHTML;
|
||||
buttonElement.disabled = true;
|
||||
buttonElement.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Adding...';
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ product_id: productId, quantity: 1 }),
|
||||
});
|
||||
|
||||
buttonElement.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
|
||||
buttonElement.classList.remove('btn-primary');
|
||||
buttonElement.classList.add('btn-success');
|
||||
|
||||
showToast(productName + ' added to cart!', 'success');
|
||||
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
try { window.updateCartCount(); } catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof window.emitCartUpdated === 'function') {
|
||||
window.emitCartUpdated();
|
||||
}
|
||||
} catch (e) { console.debug('emitCartUpdated failed:', e); }
|
||||
|
||||
setTimeout(() => {
|
||||
buttonElement.innerHTML = originalText;
|
||||
buttonElement.classList.remove('btn-success');
|
||||
buttonElement.classList.add('btn-outline-primary');
|
||||
buttonElement.disabled = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
// Let global 402 interceptor handle UI for insufficient funds
|
||||
if (error && error.status === 402) {
|
||||
buttonElement.innerHTML = originalText;
|
||||
buttonElement.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Error adding to cart:', error);
|
||||
buttonElement.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
buttonElement.classList.remove('btn-primary');
|
||||
buttonElement.classList.add('btn-danger');
|
||||
showToast('Failed to add ' + productName + ' to cart. Please try again.', 'error');
|
||||
setTimeout(() => {
|
||||
buttonElement.innerHTML = originalText;
|
||||
buttonElement.classList.remove('btn-danger');
|
||||
buttonElement.classList.add('btn-outline-primary');
|
||||
buttonElement.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
// If Bootstrap Toast is available, use it
|
||||
if (window.bootstrap && typeof window.bootstrap.Toast === 'function') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center text-white bg-' + (type === 'success' ? 'success' : 'danger') + ' border-0 position-fixed end-0 m-3';
|
||||
toast.style.top = '80px';
|
||||
toast.style.zIndex = '10000';
|
||||
toast.innerHTML = '\n <div class="d-flex">\n <div class="toast-body">\n <i class="bi bi-' + (type === 'success' ? 'check-circle' : 'exclamation-triangle') + ' me-2"></i>' + message + '\n </div>\n <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>\n </div>\n ';
|
||||
document.body.appendChild(toast);
|
||||
try {
|
||||
const bsToast = new window.bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
||||
} catch (_) {
|
||||
// Fallback remove
|
||||
setTimeout(() => toast.remove(), 3000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback simple alert-like toast
|
||||
const note = document.createElement('div');
|
||||
note.style.cssText = 'position:fixed;right:1rem;top:5rem;z-index:10000;padding:.75rem 1rem;border-radius:.25rem;color:#fff;box-shadow:0 .5rem 1rem rgba(0,0,0,.15)';
|
||||
note.style.background = type === 'success' ? '#198754' : '#dc3545';
|
||||
note.textContent = message;
|
||||
document.body.appendChild(note);
|
||||
setTimeout(() => note.remove(), 2500);
|
||||
}
|
||||
})();
|
60
src/static/js/marketplace_layout.js
Normal file
60
src/static/js/marketplace_layout.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// marketplace_layout.js
|
||||
// Handles marketplace sidebar toggle and backdrop interactions (CSP-compliant)
|
||||
|
||||
(function () {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const sidebarToggleBtn = document.getElementById('sidebarToggleBtn');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
||||
|
||||
if (!sidebarToggleBtn || !sidebar || !sidebarBackdrop) {
|
||||
// Elements not present on this page; nothing to bind
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure clean state on page load
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
// Toggle sidebar visibility
|
||||
sidebarToggleBtn.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
// Toggle visibility
|
||||
sidebar.classList.toggle('show');
|
||||
sidebarBackdrop.classList.toggle('show');
|
||||
|
||||
// Set aria-expanded for accessibility
|
||||
const isExpanded = sidebar.classList.contains('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', String(isExpanded));
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on backdrop
|
||||
sidebarBackdrop.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
// Close sidebar when clicking on any link inside it
|
||||
const sidebarLinks = sidebar.querySelectorAll('a.nav-link');
|
||||
sidebarLinks.forEach((link) => {
|
||||
link.addEventListener('click', function () {
|
||||
// Small delay to ensure the link click happens
|
||||
setTimeout(function () {
|
||||
sidebar.classList.remove('show');
|
||||
sidebarBackdrop.classList.remove('show');
|
||||
sidebarToggleBtn.setAttribute('aria-expanded', 'false');
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure links are clickable
|
||||
sidebar.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
});
|
||||
})();
|
962
src/static/js/messaging-system.js
Normal file
962
src/static/js/messaging-system.js
Normal file
@@ -0,0 +1,962 @@
|
||||
/**
|
||||
* Generic Messaging System for Project Mycelium
|
||||
* Handles communication between users and providers.
|
||||
*/
|
||||
|
||||
class MessagingSystem {
|
||||
constructor() {
|
||||
this.currentThread = null;
|
||||
this.threads = [];
|
||||
this.unreadCount = 0;
|
||||
this.isInitialized = false;
|
||||
this.pollInterval = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Set up current user email first
|
||||
this.getCurrentUserEmail();
|
||||
await this.loadThreads();
|
||||
this.setupEventListeners();
|
||||
this.startPolling();
|
||||
this.isInitialized = true;
|
||||
console.log('📨 Messaging system initialized for user:', window.currentUserEmail);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize messaging system:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all message threads for current user
|
||||
*/
|
||||
async loadThreads() {
|
||||
try {
|
||||
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
|
||||
console.log('📨 Threads API response:', JSON.stringify(data, null, 2));
|
||||
this.threads = data.threads || [];
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
console.log('📨 Loaded threads:', this.threads.length, 'unread:', this.unreadCount);
|
||||
if (this.threads.length > 0) {
|
||||
console.log('📨 First thread sample:', JSON.stringify(this.threads[0], null, 2));
|
||||
}
|
||||
this.updateUnreadBadge();
|
||||
} catch (error) {
|
||||
console.error('Error loading message threads:', error);
|
||||
this.threads = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new conversation or continue existing one
|
||||
* @param {string} recipientEmail - Email of the recipient
|
||||
* @param {string} contextType - Type of context (service_booking, slice_rental, etc.)
|
||||
* @param {string} contextId - ID of the context object
|
||||
* @param {string} subject - Subject/title of the conversation
|
||||
*/
|
||||
async startConversation(recipientEmail, contextType = 'general', contextId = null, subject = null) {
|
||||
try {
|
||||
// Check if thread already exists
|
||||
const existingThread = this.threads.find(t =>
|
||||
t.recipient_email === recipientEmail &&
|
||||
t.context_type === contextType &&
|
||||
t.context_id === contextId
|
||||
);
|
||||
|
||||
if (existingThread) {
|
||||
this.openThread(existingThread.thread_id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new thread
|
||||
const threadData = {
|
||||
recipient_email: recipientEmail,
|
||||
context_type: contextType,
|
||||
context_id: contextId,
|
||||
subject: subject || `${contextType.replace('_', ' ')} conversation`
|
||||
};
|
||||
|
||||
const response = await window.apiJson('/api/messages/threads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(threadData)
|
||||
});
|
||||
|
||||
this.currentThread = response.thread;
|
||||
this.openMessagingModal();
|
||||
await this.loadThreadMessages(response.thread.thread_id);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting conversation:', error);
|
||||
window.showNotification?.('Failed to start conversation', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open existing message thread
|
||||
*/
|
||||
async openThread(threadId) {
|
||||
try {
|
||||
const thread = this.threads.find(t => t.thread_id === threadId);
|
||||
if (!thread) {
|
||||
throw new Error('Thread not found');
|
||||
}
|
||||
|
||||
this.currentThread = thread;
|
||||
this.openMessagingModal();
|
||||
await this.loadThreadMessages(threadId);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error opening thread:', error);
|
||||
window.showNotification?.('Failed to open conversation', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for a specific thread
|
||||
*/
|
||||
async loadThreadMessages(threadId) {
|
||||
try {
|
||||
const data = await window.apiJson(`/api/messages/threads/${threadId}/messages`, { cache: 'no-store' });
|
||||
this.displayMessages(data.messages || [], threadId);
|
||||
} catch (error) {
|
||||
console.error('Error loading thread messages:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message in the current thread
|
||||
*/
|
||||
async sendMessage(threadId, content, messageType = 'text') {
|
||||
if (!content.trim()) return;
|
||||
|
||||
try {
|
||||
const messageData = {
|
||||
thread_id: threadId,
|
||||
content: content.trim(),
|
||||
message_type: messageType
|
||||
};
|
||||
|
||||
const response = await window.apiJson('/api/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(messageData)
|
||||
});
|
||||
|
||||
// Add message to UI immediately for better UX
|
||||
this.addMessageToUI(response.message, true);
|
||||
|
||||
// Clear inputs
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const panelInput = document.getElementById('conversationMessageInput');
|
||||
if (messageInput) messageInput.value = '';
|
||||
if (panelInput) panelInput.value = '';
|
||||
|
||||
// Refresh thread list to update last message
|
||||
await this.loadThreads();
|
||||
|
||||
// If we're in the panel view, refresh the conversation list
|
||||
if (document.getElementById('threadsListContainer')) {
|
||||
this.renderThreadsList();
|
||||
}
|
||||
|
||||
// Don't increment notifications for messages we send ourselves
|
||||
// The recipient will get notified via polling when they receive the message
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
window.showNotification?.('Failed to send message', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark thread as read
|
||||
*/
|
||||
async markThreadAsRead(threadId) {
|
||||
try {
|
||||
await window.apiJson(`/api/messages/threads/${threadId}/read`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
const thread = this.threads.find(t => t.thread_id === threadId);
|
||||
if (thread) {
|
||||
const readCount = thread.unread_count;
|
||||
if (readCount > 0) {
|
||||
this.unreadCount -= readCount;
|
||||
thread.unread_count = 0;
|
||||
this.updateUnreadBadge();
|
||||
|
||||
// Notify notification system
|
||||
if (window.notificationSystem) {
|
||||
window.notificationSystem.markAsRead(readCount);
|
||||
}
|
||||
|
||||
// Dispatch custom event
|
||||
document.dispatchEvent(new CustomEvent('messageRead', {
|
||||
detail: { threadId, count: readCount }
|
||||
}));
|
||||
|
||||
// Update modal thread list if it's open
|
||||
this.renderThreadsList();
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error marking thread as read:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the messaging modal
|
||||
*/
|
||||
openMessagingModal() {
|
||||
let modal = document.getElementById('messagingModal');
|
||||
if (!modal) {
|
||||
this.createMessagingModal();
|
||||
modal = document.getElementById('messagingModal');
|
||||
}
|
||||
|
||||
this.renderThreadHeader();
|
||||
const bootstrapModal = new bootstrap.Modal(modal);
|
||||
bootstrapModal.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the messaging modal HTML structure
|
||||
*/
|
||||
createMessagingModal() {
|
||||
const modalHTML = `
|
||||
<div class="modal fade" id="messagingModal" tabindex="-1" aria-labelledby="messagingModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="messagingModalLabel">Messages</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0" style="height: 500px;">
|
||||
<div class="row g-0 h-100">
|
||||
<!-- Thread List Sidebar -->
|
||||
<div class="col-md-4 border-end">
|
||||
<div class="p-3 border-bottom">
|
||||
<h6 class="mb-0">Conversations</h6>
|
||||
</div>
|
||||
<div class="thread-list" id="threadList" style="height: calc(100% - 60px); overflow-y: auto;">
|
||||
<!-- Thread list will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Area -->
|
||||
<div class="col-md-8 d-flex flex-column">
|
||||
<div class="p-3 border-bottom flex-shrink-0" id="threadHeader">
|
||||
<div class="text-center text-muted">
|
||||
Select a conversation to view messages
|
||||
</div>
|
||||
</div>
|
||||
<div class="messages-container flex-grow-1" id="messagesContainer" style="overflow-y: auto; padding: 1rem; min-height: 0;">
|
||||
<!-- Messages will be populated here -->
|
||||
</div>
|
||||
<div class="border-top p-3 flex-shrink-0" id="messageInputArea" style="display: none;">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="messageInput" placeholder="Type your message..." maxlength="1000">
|
||||
<button class="btn btn-primary" type="button" id="sendMessageBtn">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
|
||||
// Add messaging styles
|
||||
this.addMessagingStyles();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CSS styles for modern messaging appearance
|
||||
*/
|
||||
addMessagingStyles() {
|
||||
if (document.getElementById('messaging-styles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'messaging-styles';
|
||||
style.textContent = `
|
||||
.message-bubble {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.message-bubble.own-message {
|
||||
background: #007bff !important;
|
||||
color: white !important;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.message-bubble.other-message {
|
||||
background: #e9ecef !important;
|
||||
color: #212529 !important;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
.message-bubble.bg-primary {
|
||||
background: #007bff !important;
|
||||
color: white !important;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.message-bubble.bg-light {
|
||||
background: #e9ecef !important;
|
||||
color: #212529 !important;
|
||||
border-color: #dee2e6;
|
||||
}
|
||||
|
||||
.message-tail-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: -8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 8px solid transparent;
|
||||
border-left-color: #007bff;
|
||||
border-right: 0;
|
||||
border-top: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.message-tail-left::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: -8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 8px solid transparent;
|
||||
border-right-color: #f8f9fa;
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
animation: messageSlideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes messageSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.messages-container, #conversationMessages {
|
||||
background: linear-gradient(to bottom, #fafafa 0%, #ffffff 100%);
|
||||
position: relative;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
height: calc(100% - 80px) !important;
|
||||
max-height: calc(100% - 80px) !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
overflow: hidden !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
#messageInputSection {
|
||||
flex-shrink: 0 !important;
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
position: sticky !important;
|
||||
bottom: 0 !important;
|
||||
z-index: 10 !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.col-8 {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the thread header
|
||||
*/
|
||||
renderThreadHeader() {
|
||||
const header = document.getElementById('threadHeader');
|
||||
const inputArea = document.getElementById('messageInputArea');
|
||||
|
||||
if (!this.currentThread) {
|
||||
header.innerHTML = `
|
||||
<div class="text-center text-muted">
|
||||
Select a conversation to view messages
|
||||
</div>
|
||||
`;
|
||||
inputArea.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-0">${this.currentThread.subject || 'Conversation'}</h6>
|
||||
<small class="text-muted">with ${this.currentThread.recipient_email}</small>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<span class="badge bg-secondary">${this.currentThread.context_type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
inputArea.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display messages in the UI
|
||||
*/
|
||||
displayMessages(messages, threadId) {
|
||||
// Check if we're in the new panel view or old modal view
|
||||
const panelContainer = document.getElementById('conversationMessages');
|
||||
const modalContainer = document.getElementById('messagesContainer');
|
||||
|
||||
const container = panelContainer || modalContainer;
|
||||
if (!container) return;
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted">
|
||||
<i class="bi bi-chat-dots fs-1"></i>
|
||||
<p class="mt-2">No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
messages.forEach(message => {
|
||||
this.addMessageToUI(message, false, container);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render messages in the current thread
|
||||
*/
|
||||
renderMessages(messages) {
|
||||
const container = document.getElementById('messagesContainer');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
if (messages.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="bi bi-chat-dots fs-1"></i>
|
||||
<p class="mt-2">No messages yet. Start the conversation!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
messages.forEach(message => {
|
||||
this.addMessageToUI(message, false);
|
||||
});
|
||||
|
||||
// Scroll to bottom
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a single message to the UI
|
||||
*/
|
||||
addMessageToUI(message, scrollToBottom = true, targetContainer = null) {
|
||||
const container = targetContainer || document.getElementById('conversationMessages') || document.getElementById('messagesContainer');
|
||||
if (!container) {
|
||||
console.error('Message container not found - available containers:',
|
||||
document.getElementById('conversationMessages') ? 'conversationMessages found' : 'conversationMessages missing',
|
||||
document.getElementById('messagesContainer') ? 'messagesContainer found' : 'messagesContainer missing'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageTime = new Date(message.timestamp).toLocaleString();
|
||||
|
||||
// Use thread structure to determine message ownership
|
||||
// In the thread, recipient_email is the OTHER user, so if sender != recipient, it's current user's message
|
||||
const isOwnMessage = this.currentThread && message.sender_email !== this.currentThread.recipient_email;
|
||||
|
||||
// For sender name: "You" for own messages, complete email for others
|
||||
const senderName = isOwnMessage ? 'You' : message.sender_email;
|
||||
|
||||
const messageHTML = `
|
||||
<div class="message mb-3 d-flex ${isOwnMessage ? 'justify-content-end' : 'justify-content-start'}">
|
||||
<div class="message-wrapper" style="max-width: 70%;">
|
||||
${!isOwnMessage ? `<div class="small text-muted mb-1 ms-2">${senderName}</div>` : ''}
|
||||
<div class="message-bubble rounded-3 px-3 py-2 shadow-sm" style="${isOwnMessage ? 'background: #007bff !important; color: white !important;' : 'background: #e9ecef !important; color: #212529 !important;'}">
|
||||
<div class="message-content">${this.escapeHtml(message.content)}</div>
|
||||
<small class="d-block mt-1 ${isOwnMessage ? 'text-white-50' : 'text-muted'}" style="font-size: 0.7rem;">${messageTime}</small>
|
||||
</div>
|
||||
${isOwnMessage ? `<div class="small text-muted mt-1 me-2 text-end">${senderName}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', messageHTML);
|
||||
|
||||
if (scrollToBottom) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user email from various sources
|
||||
*/
|
||||
getCurrentUserEmail() {
|
||||
// Return cached value if available
|
||||
if (window.currentUserEmail) {
|
||||
return window.currentUserEmail;
|
||||
}
|
||||
|
||||
// Try to get from session data first
|
||||
const sessionData = document.getElementById('session-data');
|
||||
if (sessionData) {
|
||||
try {
|
||||
const data = JSON.parse(sessionData.textContent);
|
||||
if (data.user && data.user.email) {
|
||||
window.currentUserEmail = data.user.email;
|
||||
return data.user.email;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing session data:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from navbar dropdown data API response
|
||||
if (window.navbarData && window.navbarData.user && window.navbarData.user.email) {
|
||||
window.currentUserEmail = window.navbarData.user.email;
|
||||
return window.navbarData.user.email;
|
||||
}
|
||||
|
||||
// Try to get from user dropdown link href
|
||||
const userDropdownLink = document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]');
|
||||
if (userDropdownLink) {
|
||||
const href = userDropdownLink.getAttribute('href');
|
||||
const emailMatch = href.match(/\/dashboard\/user\/([^\/]+@[^\/]+)/);
|
||||
if (emailMatch) {
|
||||
const email = decodeURIComponent(emailMatch[1]);
|
||||
window.currentUserEmail = email;
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from API response data
|
||||
if (window.userDashboardData && window.userDashboardData.user && window.userDashboardData.user.email) {
|
||||
window.currentUserEmail = window.userDashboardData.user.email;
|
||||
return window.userDashboardData.user.email;
|
||||
}
|
||||
|
||||
// Try to get from threads API response by analyzing thread ownership
|
||||
if (this.threads && this.threads.length > 0) {
|
||||
// In threads response, the current user is NOT the recipient_email
|
||||
// Find the most common non-recipient email across threads
|
||||
const nonRecipientEmails = new Set();
|
||||
this.threads.forEach(thread => {
|
||||
// The current user should be the one who is NOT the recipient in their own thread list
|
||||
if (thread.messages && thread.messages.length > 0) {
|
||||
thread.messages.forEach(message => {
|
||||
if (message.sender_email !== thread.recipient_email) {
|
||||
nonRecipientEmails.add(message.sender_email);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (nonRecipientEmails.size === 1) {
|
||||
const email = Array.from(nonRecipientEmails)[0];
|
||||
window.currentUserEmail = email;
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract from current page URL if on dashboard/user page
|
||||
if (window.location.pathname.includes('/dashboard/user/')) {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
const emailIndex = pathParts.indexOf('user') + 1;
|
||||
if (emailIndex < pathParts.length) {
|
||||
const email = decodeURIComponent(pathParts[emailIndex]);
|
||||
if (email.includes('@')) {
|
||||
window.currentUserEmail = email;
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get from any element with data-user-email attribute
|
||||
const userEmailElement = document.querySelector('[data-user-email]');
|
||||
if (userEmailElement) {
|
||||
const email = userEmailElement.getAttribute('data-user-email');
|
||||
if (email && email.includes('@')) {
|
||||
window.currentUserEmail = email;
|
||||
return email;
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Could not determine current user email - available elements:', {
|
||||
sessionData: !!document.getElementById('session-data'),
|
||||
sessionDataContent: document.getElementById('session-data')?.textContent?.substring(0, 100),
|
||||
userDropdown: !!document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]'),
|
||||
userDropdownHref: document.querySelector('.dropdown-toggle[href*="/dashboard/user/"]')?.getAttribute('href'),
|
||||
navbarData: !!window.navbarData,
|
||||
navbarDataUser: window.navbarData?.user,
|
||||
threadsCount: this.threads ? this.threads.length : 0,
|
||||
currentPath: window.location.pathname,
|
||||
userEmailElement: !!userEmailElement,
|
||||
userEmailValue: userEmailElement?.getAttribute('data-user-email')
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Send message on button click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.id === 'sendMessageBtn' || e.target.closest('#sendMessageBtn')) {
|
||||
const input = document.getElementById('messageInput');
|
||||
if (input && input.value.trim() && this.currentThread) {
|
||||
this.sendMessage(this.currentThread.thread_id, input.value.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Send message on Enter key
|
||||
document.addEventListener('keypress', (e) => {
|
||||
if (e.target.id === 'messageInput' && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.target.value.trim() && this.currentThread) {
|
||||
this.sendMessage(this.currentThread.thread_id, e.target.value.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for new messages
|
||||
*/
|
||||
startPolling() {
|
||||
if (this.pollInterval) return;
|
||||
|
||||
this.pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const previousUnreadCount = this.unreadCount;
|
||||
await this.loadThreads();
|
||||
|
||||
// Check if we received new messages (unread count increased)
|
||||
if (this.unreadCount > previousUnreadCount) {
|
||||
// Dispatch event for notification system
|
||||
document.dispatchEvent(new CustomEvent('messageReceived', {
|
||||
detail: {
|
||||
count: this.unreadCount - previousUnreadCount,
|
||||
senderEmail: 'another user' // Generic for now
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
// If we have a current thread open, refresh its messages
|
||||
if (this.currentThread) {
|
||||
await this.loadThreadMessages(this.currentThread.thread_id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling for messages:', error);
|
||||
}
|
||||
}, 10000); // Poll every 10 seconds for faster updates
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling
|
||||
*/
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unread message badge
|
||||
*/
|
||||
updateUnreadBadge() {
|
||||
const badges = document.querySelectorAll('.message-badge, .unread-messages-badge, .navbar-message-badge');
|
||||
badges.forEach(badge => {
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount;
|
||||
badge.style.display = 'inline';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to escape HTML
|
||||
*/
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when page unloads
|
||||
*/
|
||||
destroy() {
|
||||
this.stopPolling();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Global messaging system instance
|
||||
window.messagingSystem = null;
|
||||
|
||||
// Initialize messaging system when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.messagingSystem = new MessagingSystem();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.messagingSystem) {
|
||||
window.messagingSystem.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Expose messaging functions globally for easy access
|
||||
window.startConversation = function(recipientEmail, contextType = 'general', contextId = null, subject = null) {
|
||||
if (window.messagingSystem) {
|
||||
return window.messagingSystem.startConversation(recipientEmail, contextType, contextId, subject);
|
||||
}
|
||||
};
|
||||
|
||||
window.openMessaging = function() {
|
||||
if (window.messagingSystem) {
|
||||
window.messagingSystem.openThreadsList();
|
||||
}
|
||||
};
|
||||
|
||||
// Add thread list functionality to MessagingSystem
|
||||
MessagingSystem.prototype.openThreadsList = function() {
|
||||
this.createThreadsListModal();
|
||||
const modal = new bootstrap.Modal(document.getElementById('threadsListModal'));
|
||||
modal.show();
|
||||
this.renderThreadsList();
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.createThreadsListModal = function() {
|
||||
if (document.getElementById('threadsListModal')) return;
|
||||
|
||||
const modalHTML = `
|
||||
<div class="modal fade" id="threadsListModal" tabindex="-1" aria-labelledby="threadsListModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="threadsListModalLabel">
|
||||
<i class="bi bi-chat-dots me-2"></i>Messages
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0" style="height: 600px; max-height: 80vh;">
|
||||
<div class="row g-0 h-100">
|
||||
<!-- Left Panel: Conversations List -->
|
||||
<div class="col-4 border-end">
|
||||
<div class="p-3 border-bottom bg-light">
|
||||
<h6 class="mb-0">Conversations</h6>
|
||||
</div>
|
||||
<div id="threadsListContainer" style="height: calc(100% - 60px); overflow-y: auto;">
|
||||
<div class="text-center p-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-2">Loading conversations...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right Panel: Conversation View -->
|
||||
<div class="col-8">
|
||||
<div id="conversationViewContainer" class="h-100">
|
||||
<div class="d-flex align-items-center justify-content-center h-100 text-muted">
|
||||
<div class="text-center">
|
||||
<i class="bi bi-chat-square-text fs-1"></i>
|
||||
<p class="mt-2">Select a conversation to view messages</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.renderThreadsList = function() {
|
||||
const container = document.getElementById('threadsListContainer');
|
||||
if (!container) return;
|
||||
|
||||
if (!this.threads || this.threads.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-center text-muted p-4">
|
||||
<i class="bi bi-chat-dots fs-3"></i>
|
||||
<p class="mt-2 mb-1">No conversations yet</p>
|
||||
<small>Start a conversation from your service bookings</small>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
let threadsHTML = '<div class="list-group list-group-flush">';
|
||||
|
||||
this.threads.forEach(thread => {
|
||||
const lastMessageTime = thread.last_message_at ?
|
||||
new Date(thread.last_message_at).toLocaleDateString() :
|
||||
new Date(thread.created_at).toLocaleDateString();
|
||||
|
||||
const unreadBadge = thread.unread_count > 0 ?
|
||||
`<span class="badge bg-primary rounded-pill">${thread.unread_count}</span>` : '';
|
||||
|
||||
threadsHTML += `
|
||||
<div class="list-group-item list-group-item-action border-0 px-3 py-2" onclick="window.messagingSystem.selectThreadInPanel('${thread.thread_id}')">
|
||||
<div class="d-flex w-100 justify-content-between align-items-start">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<h6 class="mb-1 fw-semibold">${this.escapeHtml(thread.subject)}</h6>
|
||||
<p class="mb-1 text-muted small">With: ${this.escapeHtml(thread.recipient_email)}</p>
|
||||
${thread.last_message ? `<small class="text-muted">${this.escapeHtml(thread.last_message.substring(0, 50))}${thread.last_message.length > 50 ? '...' : ''}</small>` : ''}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<small class="text-muted">${lastMessageTime}</small>
|
||||
${unreadBadge ? `<div class="mt-1">${unreadBadge}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
threadsHTML += '</div>';
|
||||
container.innerHTML = threadsHTML;
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.selectThreadInPanel = function(threadId) {
|
||||
// Find the thread
|
||||
const thread = this.threads.find(t => t.thread_id === threadId);
|
||||
if (!thread) return;
|
||||
|
||||
this.currentThread = thread;
|
||||
this.renderConversationView(threadId);
|
||||
this.loadThreadMessages(threadId);
|
||||
// Always mark thread as read when selected
|
||||
this.markThreadAsRead(threadId);
|
||||
// Update active state in thread list
|
||||
const container = document.getElementById('threadsListContainer');
|
||||
if (container) {
|
||||
container.querySelectorAll('.list-group-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
container.querySelector(`[onclick*="${threadId}"]`)?.classList.add('active');
|
||||
}
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.renderConversationView = function(threadId) {
|
||||
const container = document.getElementById('conversationViewContainer');
|
||||
if (!container || !this.currentThread) return;
|
||||
|
||||
const thread = this.currentThread;
|
||||
|
||||
const conversationHTML = `
|
||||
<div class="h-100 d-flex flex-column">
|
||||
<!-- Conversation Header -->
|
||||
<div class="p-3 border-bottom bg-light flex-shrink-0">
|
||||
<h6 class="mb-1">${this.escapeHtml(thread.subject)}</h6>
|
||||
<small class="text-muted">With: ${this.escapeHtml(thread.recipient_email)}</small>
|
||||
</div>
|
||||
|
||||
<!-- Messages Container -->
|
||||
<div id="conversationMessages" class="p-3" style="flex: 1; overflow-y: auto; overflow-x: hidden; background: linear-gradient(to bottom, #fafafa 0%, #ffffff 100%);">
|
||||
<div class="text-center">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading messages...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Input -->
|
||||
<div class="p-3 border-top" id="messageInputSection" style="flex-shrink: 0;">
|
||||
<div class="input-group">
|
||||
<input type="text" id="conversationMessageInput" class="form-control" placeholder="Type your message..." onkeypress="if(event.key==='Enter') window.messagingSystem.sendMessageFromPanel()">
|
||||
<button class="btn btn-primary" onclick="window.messagingSystem.sendMessageFromPanel()">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = conversationHTML;
|
||||
this.addMessagingStyles();
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.sendMessageFromPanel = function() {
|
||||
const input = document.getElementById('conversationMessageInput');
|
||||
if (!input || !input.value.trim() || !this.currentThread) {
|
||||
console.log('Send message failed:', {
|
||||
input: !!input,
|
||||
hasValue: input ? !!input.value.trim() : false,
|
||||
hasThread: !!this.currentThread
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const content = input.value.trim();
|
||||
input.value = '';
|
||||
|
||||
this.sendMessage(this.currentThread.thread_id, content);
|
||||
};
|
||||
|
||||
MessagingSystem.prototype.openThreadFromList = function(threadId) {
|
||||
// Close threads list modal
|
||||
const threadsModal = bootstrap.Modal.getInstance(document.getElementById('threadsListModal'));
|
||||
if (threadsModal) {
|
||||
threadsModal.hide();
|
||||
}
|
||||
|
||||
// Open the specific thread in the old modal
|
||||
this.openThread(threadId);
|
||||
};
|
199
src/static/js/modal-system.js
Normal file
199
src/static/js/modal-system.js
Normal file
@@ -0,0 +1,199 @@
|
||||
// Modal System for Project Mycelium
|
||||
class ModalSystem {
|
||||
constructor() {
|
||||
this.modals = new Map();
|
||||
this.initializeModalContainer();
|
||||
}
|
||||
|
||||
initializeModalContainer() {
|
||||
// Create modal container if it doesn't exist
|
||||
if (!document.getElementById('modal-container')) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'modal-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
}
|
||||
|
||||
showModal(id, options = {}) {
|
||||
const {
|
||||
title = 'Notification',
|
||||
message = '',
|
||||
type = 'info', // info, success, error, warning, confirm
|
||||
confirmText = 'OK',
|
||||
cancelText = 'Cancel',
|
||||
showCancel = false,
|
||||
onConfirm = () => {},
|
||||
onCancel = () => {},
|
||||
onClose = () => {}
|
||||
} = options;
|
||||
|
||||
// Remove existing modal with same ID
|
||||
this.hideModal(id);
|
||||
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="${id}" tabindex="-1" aria-labelledby="${id}Label" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header ${this.getHeaderClass(type)}">
|
||||
<h5 class="modal-title" id="${id}Label">
|
||||
${this.getIcon(type)} ${title}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
${message}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
${showCancel ? `<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${cancelText}</button>` : ''}
|
||||
<button type="button" class="btn ${this.getButtonClass(type)}" id="${id}-confirm">${confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add modal to container
|
||||
const container = document.getElementById('modal-container');
|
||||
container.insertAdjacentHTML('beforeend', modalHtml);
|
||||
|
||||
// Get modal element
|
||||
const modalElement = document.getElementById(id);
|
||||
const modal = new bootstrap.Modal(modalElement);
|
||||
|
||||
// Store modal reference
|
||||
this.modals.set(id, modal);
|
||||
|
||||
// Add event listeners
|
||||
const confirmBtn = document.getElementById(`${id}-confirm`);
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
onConfirm();
|
||||
modal.hide();
|
||||
});
|
||||
|
||||
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||
onClose();
|
||||
this.hideModal(id);
|
||||
});
|
||||
|
||||
// Show modal
|
||||
modal.show();
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
hideModal(id) {
|
||||
const modal = this.modals.get(id);
|
||||
if (modal) {
|
||||
modal.hide();
|
||||
this.modals.delete(id);
|
||||
}
|
||||
|
||||
// Remove modal element from DOM
|
||||
const modalElement = document.getElementById(id);
|
||||
if (modalElement) {
|
||||
modalElement.remove();
|
||||
}
|
||||
}
|
||||
|
||||
getHeaderClass(type) {
|
||||
switch (type) {
|
||||
case 'success': return 'bg-success text-white';
|
||||
case 'error': return 'bg-danger text-white';
|
||||
case 'warning': return 'bg-warning text-dark';
|
||||
case 'confirm': return 'bg-primary text-white';
|
||||
default: return 'bg-light';
|
||||
}
|
||||
}
|
||||
|
||||
getButtonClass(type) {
|
||||
switch (type) {
|
||||
case 'success': return 'btn-success';
|
||||
case 'error': return 'btn-danger';
|
||||
case 'warning': return 'btn-warning';
|
||||
case 'confirm': return 'btn-primary';
|
||||
default: return 'btn-primary';
|
||||
}
|
||||
}
|
||||
|
||||
getIcon(type) {
|
||||
switch (type) {
|
||||
case 'success': return '<i class="bi bi-check-circle-fill"></i>';
|
||||
case 'error': return '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||||
case 'warning': return '<i class="bi bi-exclamation-triangle-fill"></i>';
|
||||
case 'confirm': return '<i class="bi bi-question-circle-fill"></i>';
|
||||
default: return '<i class="bi bi-info-circle-fill"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
showSuccess(title, message, onConfirm = () => {}) {
|
||||
return this.showModal('success-modal', {
|
||||
title,
|
||||
message,
|
||||
type: 'success',
|
||||
confirmText: 'Great!',
|
||||
onConfirm
|
||||
});
|
||||
}
|
||||
|
||||
showError(title, message, onConfirm = () => {}) {
|
||||
return this.showModal('error-modal', {
|
||||
title,
|
||||
message,
|
||||
type: 'error',
|
||||
confirmText: 'OK',
|
||||
onConfirm
|
||||
});
|
||||
}
|
||||
|
||||
showConfirm(title, message, onConfirm = () => {}, onCancel = () => {}) {
|
||||
return this.showModal('confirm-modal', {
|
||||
title,
|
||||
message,
|
||||
type: 'confirm',
|
||||
confirmText: 'Yes',
|
||||
cancelText: 'No',
|
||||
showCancel: true,
|
||||
onConfirm,
|
||||
onCancel
|
||||
});
|
||||
}
|
||||
|
||||
showAuthRequired(onConfirm = () => {}) {
|
||||
return this.showModal('auth-required-modal', {
|
||||
title: 'Authentication Required',
|
||||
message: 'Please log in or register to make purchases. Would you like to go to the dashboard to continue?',
|
||||
type: 'confirm',
|
||||
confirmText: 'Go to Dashboard',
|
||||
cancelText: 'Cancel',
|
||||
showCancel: true,
|
||||
onConfirm: () => {
|
||||
window.location.href = '/dashboard';
|
||||
onConfirm();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
showInsufficientBalance(shortfall, onTopUp = () => {}) {
|
||||
return this.showModal('insufficient-balance-modal', {
|
||||
title: 'Insufficient Balance',
|
||||
message: `You need $${shortfall.toFixed(2)} more in your wallet to complete this purchase. Would you like to add credits to your wallet?`,
|
||||
type: 'warning',
|
||||
confirmText: 'Add Credits',
|
||||
cancelText: 'Cancel',
|
||||
showCancel: true,
|
||||
onConfirm: () => {
|
||||
window.location.href = '/dashboard/wallet?action=topup';
|
||||
onTopUp();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Global modal system instance
|
||||
window.modalSystem = new ModalSystem();
|
||||
|
||||
// Global convenience functions
|
||||
window.showSuccessModal = (title, message, onConfirm) => window.modalSystem.showSuccess(title, message, onConfirm);
|
||||
window.showErrorModal = (title, message, onConfirm) => window.modalSystem.showError(title, message, onConfirm);
|
||||
window.showConfirmModal = (title, message, onConfirm, onCancel) => window.modalSystem.showConfirm(title, message, onConfirm, onCancel);
|
326
src/static/js/notification-system.js
Normal file
326
src/static/js/notification-system.js
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Enhanced Notification System for Project Mycelium
|
||||
* Provides industry-standard message notifications across the platform
|
||||
*/
|
||||
|
||||
class NotificationSystem {
|
||||
constructor() {
|
||||
this.unreadCount = 0;
|
||||
this.lastNotificationCheck = null;
|
||||
this.notificationInterval = null;
|
||||
this.isInitialized = false;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
await this.loadUnreadCount();
|
||||
this.startNotificationPolling();
|
||||
this.setupEventListeners();
|
||||
this.isInitialized = true;
|
||||
console.log('🔔 Notification system initialized');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize notification system:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current unread message count
|
||||
*/
|
||||
async loadUnreadCount() {
|
||||
try {
|
||||
// Use the existing threads endpoint which includes unread_count
|
||||
const data = await window.apiJson('/api/messages/threads', { cache: 'no-store' });
|
||||
console.log('🔔 Notification API response:', JSON.stringify(data, null, 2));
|
||||
this.unreadCount = data.unread_count || 0;
|
||||
this.updateAllBadges();
|
||||
console.log('📊 Notification system: unread count =', this.unreadCount);
|
||||
} catch (error) {
|
||||
console.error('Error loading unread count:', error);
|
||||
this.unreadCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all notification badges across the platform
|
||||
*/
|
||||
updateAllBadges() {
|
||||
const selectors = [
|
||||
'.message-badge',
|
||||
];
|
||||
|
||||
selectors.forEach(selector => {
|
||||
const badges = document.querySelectorAll(selector);
|
||||
badges.forEach(badge => {
|
||||
if (this.unreadCount > 0) {
|
||||
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
|
||||
badge.style.display = 'inline';
|
||||
badge.classList.add('animate-pulse');
|
||||
|
||||
// Remove pulse animation after 2 seconds
|
||||
setTimeout(() => {
|
||||
badge.classList.remove('animate-pulse');
|
||||
}, 2000);
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
badge.classList.remove('animate-pulse');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update sidebar badge
|
||||
const sidebarBadge = document.querySelector('.sidebar-message-count');
|
||||
if (sidebarBadge) {
|
||||
if (this.unreadCount > 0) {
|
||||
sidebarBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
|
||||
sidebarBadge.style.display = 'inline-block';
|
||||
sidebarBadge.classList.add('pulse-animation');
|
||||
} else {
|
||||
sidebarBadge.style.display = 'none';
|
||||
sidebarBadge.classList.remove('pulse-animation');
|
||||
}
|
||||
}
|
||||
|
||||
// Update navbar badge
|
||||
const navbarBadge = document.getElementById('navbar-message-badge');
|
||||
if (navbarBadge) {
|
||||
if (this.unreadCount > 0) {
|
||||
navbarBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
|
||||
navbarBadge.classList.remove('d-none');
|
||||
} else {
|
||||
navbarBadge.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
// Update dropdown badge
|
||||
const dropdownBadge = document.getElementById('dropdown-message-count');
|
||||
if (dropdownBadge) {
|
||||
dropdownBadge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount.toString();
|
||||
}
|
||||
|
||||
// Update document title with unread count
|
||||
this.updateDocumentTitle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update document title to show unread count
|
||||
*/
|
||||
updateDocumentTitle() {
|
||||
const baseTitle = 'Project Mycelium';
|
||||
if (this.unreadCount > 0) {
|
||||
document.title = `(${this.unreadCount}) ${baseTitle}`;
|
||||
} else {
|
||||
document.title = baseTitle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start polling for notification updates
|
||||
*/
|
||||
startNotificationPolling() {
|
||||
if (this.notificationInterval) return;
|
||||
|
||||
// Initial load
|
||||
this.loadUnreadCount();
|
||||
|
||||
this.notificationInterval = setInterval(async () => {
|
||||
try {
|
||||
const previousCount = this.unreadCount;
|
||||
await this.loadUnreadCount();
|
||||
|
||||
// Show desktop notification for new messages
|
||||
if (this.unreadCount > previousCount && this.hasNotificationPermission()) {
|
||||
this.showDesktopNotification(
|
||||
'New Message',
|
||||
`You have ${this.unreadCount - previousCount} new message(s)`,
|
||||
'/static/images/logo_light.png'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling notifications:', error);
|
||||
}
|
||||
}, 10000); // Poll every 10 seconds for faster updates
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop notification polling
|
||||
*/
|
||||
stopNotificationPolling() {
|
||||
if (this.notificationInterval) {
|
||||
clearInterval(this.notificationInterval);
|
||||
this.notificationInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser supports and has permission for desktop notifications
|
||||
*/
|
||||
hasNotificationPermission() {
|
||||
return 'Notification' in window && Notification.permission === 'granted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Request desktop notification permission
|
||||
*/
|
||||
async requestNotificationPermission() {
|
||||
if (!('Notification' in window)) {
|
||||
console.log('This browser does not support desktop notifications');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Notification.permission === 'granted') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Notification.permission !== 'denied') {
|
||||
const permission = await Notification.requestPermission();
|
||||
return permission === 'granted';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show desktop notification
|
||||
*/
|
||||
showDesktopNotification(title, body, icon = null) {
|
||||
if (!this.hasNotificationPermission()) return;
|
||||
|
||||
const notification = new Notification(title, {
|
||||
body: body,
|
||||
icon: icon,
|
||||
badge: icon,
|
||||
tag: 'threefold-message',
|
||||
requireInteraction: false,
|
||||
silent: false
|
||||
});
|
||||
|
||||
// Auto-close after 5 seconds
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 5000);
|
||||
|
||||
// Handle click to open messages
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
if (window.openMessaging) {
|
||||
window.openMessaging();
|
||||
}
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read and update count
|
||||
*/
|
||||
markAsRead(count = null) {
|
||||
if (count !== null) {
|
||||
this.unreadCount = Math.max(0, this.unreadCount - count);
|
||||
} else {
|
||||
this.unreadCount = 0;
|
||||
}
|
||||
this.updateAllBadges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new unread messages
|
||||
*/
|
||||
addUnread(count = 1) {
|
||||
this.unreadCount += count;
|
||||
this.updateAllBadges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners
|
||||
*/
|
||||
setupEventListeners() {
|
||||
// Listen for messaging system updates
|
||||
document.addEventListener('messageRead', (event) => {
|
||||
this.markAsRead(event.detail.count);
|
||||
});
|
||||
|
||||
// Listen for new messages received by this user (not sent by them)
|
||||
document.addEventListener('messageReceived', (event) => {
|
||||
this.addUnread(1);
|
||||
|
||||
// Show desktop notification immediately
|
||||
if (this.hasNotificationPermission()) {
|
||||
this.showDesktopNotification(
|
||||
'New Message',
|
||||
`New message from ${event.detail.senderEmail}`,
|
||||
'/static/images/logo_light.png'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Request notification permission on first user interaction
|
||||
document.addEventListener('click', () => {
|
||||
this.requestNotificationPermission();
|
||||
}, { once: true });
|
||||
|
||||
// Handle visibility change to refresh when tab becomes active
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) {
|
||||
this.loadUnreadCount();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup when page unloads
|
||||
*/
|
||||
destroy() {
|
||||
this.stopNotificationPolling();
|
||||
this.isInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Global notification system instance
|
||||
window.notificationSystem = null;
|
||||
|
||||
// Initialize notification system when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Only initialize if user is logged in
|
||||
if (document.querySelector('#userDropdown')) {
|
||||
window.notificationSystem = new NotificationSystem();
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.notificationSystem) {
|
||||
window.notificationSystem.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// Add CSS for pulse animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.animate-pulse {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
font-size: 0.7rem;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
276
src/static/js/orders.js
Normal file
276
src/static/js/orders.js
Normal file
@@ -0,0 +1,276 @@
|
||||
// Orders page logic externalized for CSP compliance
|
||||
(function () {
|
||||
function initialize() {
|
||||
// Initialize filters from URL
|
||||
initializeFilters();
|
||||
|
||||
// Bind filters
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const dateFilter = document.getElementById('dateRange');
|
||||
if (statusFilter) statusFilter.addEventListener('change', filterOrdersClientSide);
|
||||
if (dateFilter) dateFilter.addEventListener('change', filterOrdersClientSide);
|
||||
|
||||
// Bind export button
|
||||
const exportBtn = document.querySelector('[data-action="export-orders"], #export-orders');
|
||||
if (exportBtn && !exportBtn.dataset.bound) {
|
||||
exportBtn.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
exportOrders();
|
||||
});
|
||||
exportBtn.dataset.bound = '1';
|
||||
}
|
||||
|
||||
// Initial filter pass
|
||||
filterOrdersClientSide();
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const currentStatus = urlParams.get('status');
|
||||
const currentDays = urlParams.get('days');
|
||||
|
||||
if (currentStatus) {
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
if (statusFilter) statusFilter.value = currentStatus;
|
||||
}
|
||||
|
||||
if (currentDays) {
|
||||
const dateRange = document.getElementById('dateRange');
|
||||
if (dateRange) dateRange.value = currentDays;
|
||||
}
|
||||
}
|
||||
|
||||
function filterOrdersClientSide() {
|
||||
const statusFilter = document.getElementById('statusFilter')?.value;
|
||||
const dateFilter = document.getElementById('dateRange')?.value;
|
||||
const orderCards = document.querySelectorAll('.order-card');
|
||||
|
||||
orderCards.forEach((card) => {
|
||||
let showCard = true;
|
||||
|
||||
if (statusFilter && statusFilter !== '') {
|
||||
const statusBadge = card.querySelector('.status-badge');
|
||||
if (statusBadge) {
|
||||
const orderStatus = statusBadge.textContent.trim().toLowerCase();
|
||||
const filterStatus = statusFilter.toLowerCase();
|
||||
if (!orderStatus.includes(filterStatus)) {
|
||||
showCard = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dateFilter && dateFilter !== '' && showCard) {
|
||||
const dateElement = card.querySelector('.text-muted');
|
||||
if (dateElement) {
|
||||
const dateText = dateElement.textContent.replace('Placed on ', '');
|
||||
const orderDate = new Date(dateText);
|
||||
const now = new Date();
|
||||
const daysAgo = parseInt(dateFilter);
|
||||
const cutoffDate = new Date(now.getTime() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
if (orderDate < cutoffDate) {
|
||||
showCard = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
card.style.display = showCard ? 'block' : 'none';
|
||||
});
|
||||
|
||||
updateVisibleOrderCount();
|
||||
showNoResultsMessage();
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const dateRange = document.getElementById('dateRange');
|
||||
if (statusFilter) statusFilter.value = '';
|
||||
if (dateRange) dateRange.value = '';
|
||||
filterOrdersClientSide();
|
||||
}
|
||||
|
||||
function showNoResultsMessage() {
|
||||
const visibleOrders = document.querySelectorAll(
|
||||
'.order-card[style*="block"], .order-card:not([style*="none"])'
|
||||
);
|
||||
const ordersContainer = document.querySelector('.col-lg-8');
|
||||
|
||||
const existingMessage = document.getElementById('no-results-message');
|
||||
if (existingMessage) existingMessage.remove();
|
||||
|
||||
if (visibleOrders.length === 0 && ordersContainer) {
|
||||
const noResultsDiv = document.createElement('div');
|
||||
noResultsDiv.id = 'no-results-message';
|
||||
noResultsDiv.className = 'text-center py-5';
|
||||
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'bi bi-search fs-1 text-muted mb-3';
|
||||
const h5 = document.createElement('h5');
|
||||
h5.className = 'text-muted';
|
||||
h5.textContent = 'No orders match your filters';
|
||||
const p = document.createElement('p');
|
||||
p.className = 'text-muted';
|
||||
p.textContent = 'Try adjusting your filter criteria to see more results.';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn btn-outline-primary';
|
||||
btn.innerHTML = '<i class="bi bi-arrow-clockwise me-1"></i>Clear Filters';
|
||||
btn.addEventListener('click', clearAllFilters);
|
||||
|
||||
noResultsDiv.appendChild(icon);
|
||||
noResultsDiv.appendChild(h5);
|
||||
noResultsDiv.appendChild(p);
|
||||
noResultsDiv.appendChild(btn);
|
||||
|
||||
ordersContainer.appendChild(noResultsDiv);
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibleOrderCount() {
|
||||
const visibleOrders = document.querySelectorAll(
|
||||
'.order-card[style*="block"], .order-card:not([style*="none"])'
|
||||
);
|
||||
const totalOrders = document.querySelectorAll('.order-card');
|
||||
const countElements = document.querySelectorAll('.order-count');
|
||||
countElements.forEach((el) => {
|
||||
el.textContent = `${visibleOrders.length} of ${totalOrders.length}`;
|
||||
});
|
||||
}
|
||||
|
||||
function exportOrders() {
|
||||
const exportData = [];
|
||||
|
||||
Array.from(document.querySelectorAll('.order-card'))
|
||||
.filter((card) => card.style.display !== 'none')
|
||||
.forEach((card) => {
|
||||
const orderId = card.querySelector('h5')?.textContent?.replace('Order #', '') || '';
|
||||
const status = card.querySelector('.status-badge')?.textContent?.trim().replace(/\s+/g, ' ') || '';
|
||||
const date = card.querySelector('.text-muted')?.textContent?.replace('Placed on ', '') || '';
|
||||
const total = card.querySelector('.h4.text-primary')?.textContent?.trim() || '';
|
||||
const paymentMethod = card.querySelector('.col-md-4 .mb-3')?.textContent?.trim() || '';
|
||||
const confirmationNumber = card.querySelector('.card-footer strong')?.textContent?.trim() || '';
|
||||
|
||||
const itemElements = card.querySelectorAll('.d-flex.align-items-center.mb-2');
|
||||
|
||||
if (itemElements.length > 0) {
|
||||
itemElements.forEach((itemElement) => {
|
||||
const productName = itemElement.querySelector('.fw-bold')?.textContent?.trim() || '';
|
||||
const itemDetails = itemElement.querySelector('.text-muted')?.textContent?.trim() || '';
|
||||
const itemPrice = itemElement.querySelector('.text-end .fw-bold')?.textContent?.trim() || '';
|
||||
|
||||
const detailsParts = itemDetails.split(' • ');
|
||||
const provider = detailsParts[0] || '';
|
||||
const quantityMatch = itemDetails.match(/Qty:\s*(\d+)/);
|
||||
const quantity = quantityMatch ? quantityMatch[1] : '';
|
||||
|
||||
let category = '';
|
||||
const iconElement = itemElement.querySelector('i[class*="bi-"]');
|
||||
if (iconElement) {
|
||||
if (iconElement.classList.contains('bi-cpu')) category = 'Compute';
|
||||
else if (iconElement.classList.contains('bi-hdd-rack')) category = 'Hardware';
|
||||
else if (iconElement.classList.contains('bi-globe')) category = 'Gateways';
|
||||
else if (iconElement.classList.contains('bi-app')) category = 'Applications';
|
||||
else if (iconElement.classList.contains('bi-person-workspace')) category = 'Services';
|
||||
else category = 'Other';
|
||||
}
|
||||
|
||||
exportData.push({
|
||||
'Order ID': orderId,
|
||||
Status: status,
|
||||
Date: date,
|
||||
'Product Name': productName,
|
||||
Category: category,
|
||||
Provider: provider,
|
||||
Quantity: quantity,
|
||||
'Item Price': itemPrice,
|
||||
'Order Total': total,
|
||||
'Payment Method': paymentMethod,
|
||||
'Confirmation Number': confirmationNumber,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
exportData.push({
|
||||
'Order ID': orderId,
|
||||
Status: status,
|
||||
Date: date,
|
||||
'Product Name': '',
|
||||
Category: '',
|
||||
Provider: '',
|
||||
Quantity: '',
|
||||
'Item Price': '',
|
||||
'Order Total': total,
|
||||
'Payment Method': paymentMethod,
|
||||
'Confirmation Number': confirmationNumber,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (exportData.length === 0) {
|
||||
showToast('No orders to export', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const csvContent = convertToCSV(exportData);
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `threefold_orders_detailed_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
const orderCount = new Set(exportData.map((row) => row['Order ID'])).size;
|
||||
showToast(`Exported ${orderCount} orders with ${exportData.length} items successfully`, 'success');
|
||||
}
|
||||
|
||||
function convertToCSV(data) {
|
||||
if (data.length === 0) return '';
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvRows = [];
|
||||
csvRows.push(headers.join(','));
|
||||
for (const row of data) {
|
||||
const values = headers.map((header) => {
|
||||
const value = row[header] || '';
|
||||
if (value.includes(',') || value.includes('\n') || value.includes('"')) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
csvRows.push(values.join(','));
|
||||
}
|
||||
return csvRows.join('\n');
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-white bg-${type} border-0 position-fixed end-0 m-3`;
|
||||
toast.style.top = '80px';
|
||||
toast.style.zIndex = '10000';
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">
|
||||
<i class="bi bi-info-circle me-2"></i>${message}
|
||||
</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(toast);
|
||||
const bsToast = new bootstrap.Toast(toast);
|
||||
bsToast.show();
|
||||
toast.addEventListener('hidden.bs.toast', () => toast.remove());
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initialize);
|
||||
} else {
|
||||
initialize();
|
||||
}
|
||||
|
||||
// Expose helpers for potential future use
|
||||
window.OrdersPage = {
|
||||
filter: filterOrdersClientSide,
|
||||
clearFilters: clearAllFilters,
|
||||
export: exportOrders,
|
||||
};
|
||||
})();
|
38
src/static/js/print-utils.js
Normal file
38
src/static/js/print-utils.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// Shared print utilities for CSP-compliant pages
|
||||
// Binds click listeners to elements with class `.js-print` or `[data-action="print"]`
|
||||
(function () {
|
||||
function bindPrintButtons() {
|
||||
const handler = function (e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
window.print();
|
||||
} catch (err) {
|
||||
// Silently fail; printing may be blocked in some contexts
|
||||
if (window && window.console) {
|
||||
console.warn('Print action failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Bind to static buttons present at load
|
||||
const selectors = ['.js-print', '[data-action="print"]'];
|
||||
document.querySelectorAll(selectors.join(',')).forEach((el) => {
|
||||
// Avoid double-binding
|
||||
if (!el.dataset.printBound) {
|
||||
el.addEventListener('click', handler);
|
||||
el.dataset.printBound = '1';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', bindPrintButtons);
|
||||
} else {
|
||||
bindPrintButtons();
|
||||
}
|
||||
|
||||
// Expose a minimal API in case pages need to (re)bind after dynamic content updates
|
||||
window.PrintUtils = {
|
||||
bind: bindPrintButtons,
|
||||
};
|
||||
})();
|
98
src/static/js/product-detail-step2.js
Normal file
98
src/static/js/product-detail-step2.js
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Product detail step 2 functionality
|
||||
* Handles quantity controls and add-to-cart with price calculation
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const quantityInput = document.getElementById('quantity');
|
||||
const decreaseBtn = document.getElementById('decreaseQty');
|
||||
const increaseBtn = document.getElementById('increaseQty');
|
||||
const totalPriceElement = document.getElementById('totalPrice');
|
||||
const addToCartBtn = document.getElementById('addToCartBtn');
|
||||
|
||||
if (!quantityInput || !addToCartBtn) return;
|
||||
|
||||
const unitPrice = parseFloat(addToCartBtn.dataset.unitPrice);
|
||||
const currency = addToCartBtn.dataset.currency;
|
||||
|
||||
// Quantity controls
|
||||
if (decreaseBtn) {
|
||||
decreaseBtn.addEventListener('click', function() {
|
||||
const currentValue = parseInt(quantityInput.value);
|
||||
if (currentValue > 1) {
|
||||
quantityInput.value = currentValue - 1;
|
||||
updateTotalPrice();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (increaseBtn) {
|
||||
increaseBtn.addEventListener('click', function() {
|
||||
const currentValue = parseInt(quantityInput.value);
|
||||
if (currentValue < 10) {
|
||||
quantityInput.value = currentValue + 1;
|
||||
updateTotalPrice();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
quantityInput.addEventListener('change', function() {
|
||||
const value = parseInt(this.value);
|
||||
if (value < 1) this.value = 1;
|
||||
if (value > 10) this.value = 10;
|
||||
updateTotalPrice();
|
||||
});
|
||||
|
||||
function updateTotalPrice() {
|
||||
if (!totalPriceElement) return;
|
||||
const quantity = parseInt(quantityInput.value);
|
||||
const total = (unitPrice * quantity).toFixed(2);
|
||||
totalPriceElement.textContent = `${total} ${currency}`;
|
||||
}
|
||||
|
||||
// Add to cart functionality
|
||||
addToCartBtn.addEventListener('click', async function() {
|
||||
const quantity = parseInt(quantityInput.value);
|
||||
const productId = this.dataset.productId;
|
||||
const productName = this.dataset.productName;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
window.setButtonLoading(this, '<i class="bi bi-hourglass-split me-2"></i>Adding...');
|
||||
|
||||
// Add to cart API call using apiJson
|
||||
const response = await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: quantity
|
||||
})
|
||||
});
|
||||
|
||||
// Show success message
|
||||
window.setButtonSuccess(this, 'Added!', 2000);
|
||||
|
||||
// Update cart count in header
|
||||
if (window.updateCartCount) {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
// Notify other listeners about cart update
|
||||
try {
|
||||
const meta = (response && response.metadata) ? response.metadata : {};
|
||||
if (typeof window.emitCartUpdated === 'function') {
|
||||
window.emitCartUpdated(meta.cart_count);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('cartUpdated event dispatch failed:', e);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding to cart:', error);
|
||||
window.handleApiError(error, 'adding item to cart', this);
|
||||
}
|
||||
});
|
||||
});
|
171
src/static/js/product-detail.js
Normal file
171
src/static/js/product-detail.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Product detail page functionality
|
||||
* Migrated from inline scripts to use apiJson and shared error handlers
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const quantityInput = document.getElementById('quantity');
|
||||
const decreaseBtn = document.getElementById('decreaseQty');
|
||||
const increaseBtn = document.getElementById('increaseQty');
|
||||
const totalPriceElement = document.getElementById('totalPrice');
|
||||
const addToCartBtn = document.getElementById('addToCartBtn');
|
||||
const currencySelector = document.getElementById('currencySelector');
|
||||
|
||||
// Get pricing data from button attributes
|
||||
const unitPrice = addToCartBtn ? parseFloat(addToCartBtn.dataset.unitPrice) : 0;
|
||||
const currency = addToCartBtn ? addToCartBtn.dataset.currency : 'USD';
|
||||
|
||||
// Quantity controls
|
||||
if (decreaseBtn && increaseBtn && quantityInput) {
|
||||
decreaseBtn.addEventListener('click', function() {
|
||||
const currentValue = parseInt(quantityInput.value);
|
||||
if (currentValue > 1) {
|
||||
quantityInput.value = currentValue - 1;
|
||||
updateTotalPrice();
|
||||
}
|
||||
});
|
||||
|
||||
increaseBtn.addEventListener('click', function() {
|
||||
const currentValue = parseInt(quantityInput.value);
|
||||
if (currentValue < 10) {
|
||||
quantityInput.value = currentValue + 1;
|
||||
updateTotalPrice();
|
||||
}
|
||||
});
|
||||
|
||||
quantityInput.addEventListener('change', function() {
|
||||
const value = parseInt(this.value);
|
||||
if (value < 1) this.value = 1;
|
||||
if (value > 10) this.value = 10;
|
||||
updateTotalPrice();
|
||||
});
|
||||
}
|
||||
|
||||
function updateTotalPrice() {
|
||||
if (totalPriceElement && quantityInput) {
|
||||
const quantity = parseInt(quantityInput.value);
|
||||
const total = (unitPrice * quantity).toFixed(2);
|
||||
totalPriceElement.textContent = `${total} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Add to cart functionality for main product
|
||||
if (addToCartBtn) {
|
||||
addToCartBtn.addEventListener('click', async function() {
|
||||
const quantity = parseInt(quantityInput?.value) || 1;
|
||||
const productId = this.dataset.productId;
|
||||
const productName = this.dataset.productName;
|
||||
|
||||
if (!productId) {
|
||||
showErrorToast('Product ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(this, 'Adding...');
|
||||
|
||||
try {
|
||||
const data = await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: quantity
|
||||
})
|
||||
});
|
||||
|
||||
setButtonSuccess(this, 'Added!');
|
||||
showSuccessToast('Item added to cart');
|
||||
|
||||
// Update cart count in navbar
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
// Notify other listeners about cart update
|
||||
try {
|
||||
const meta = (data && data.metadata) ? data.metadata : {};
|
||||
if (typeof window.emitCartUpdated === 'function') {
|
||||
window.emitCartUpdated(meta.cart_count);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('cartUpdated event dispatch failed:', e);
|
||||
}
|
||||
} catch (error) {
|
||||
handleApiError(error, 'adding to cart', this);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Currency selector
|
||||
if (currencySelector) {
|
||||
currencySelector.addEventListener('change', async function() {
|
||||
const newCurrency = this.value;
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/user/currency', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
currency: newCurrency
|
||||
})
|
||||
});
|
||||
|
||||
// Reload page to show new prices
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
handleApiError(error, 'updating currency');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add to cart for recommendation buttons
|
||||
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const productId = this.dataset.productId;
|
||||
const productName = this.dataset.productName;
|
||||
|
||||
if (!productId) {
|
||||
showErrorToast('Product ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
setButtonLoading(this, 'Adding...');
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: 1
|
||||
})
|
||||
});
|
||||
|
||||
// Show success state
|
||||
this.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
|
||||
this.classList.remove('btn-outline-primary');
|
||||
this.classList.add('btn-success');
|
||||
|
||||
showSuccessToast(`${productName} added to cart!`);
|
||||
|
||||
// Update cart count in navbar
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
// Reset button after 2 seconds
|
||||
setTimeout(() => {
|
||||
resetButton(this);
|
||||
this.classList.remove('btn-success');
|
||||
this.classList.add('btn-outline-primary');
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'adding to cart', this);
|
||||
|
||||
// Reset button styling after error
|
||||
setTimeout(() => {
|
||||
this.classList.remove('btn-danger');
|
||||
this.classList.add('btn-outline-primary');
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
72
src/static/js/products-page.js
Normal file
72
src/static/js/products-page.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Products page functionality
|
||||
* Handles view mode toggle and add-to-cart functionality
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// View mode toggle
|
||||
const gridView = document.getElementById('grid-view');
|
||||
const listView = document.getElementById('list-view');
|
||||
const productsGrid = document.getElementById('products-grid');
|
||||
const productsList = document.getElementById('products-list');
|
||||
|
||||
if (gridView && listView && productsGrid && productsList) {
|
||||
gridView.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
productsGrid.classList.remove('d-none');
|
||||
productsList.classList.add('d-none');
|
||||
}
|
||||
});
|
||||
|
||||
listView.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
productsGrid.classList.add('d-none');
|
||||
productsList.classList.remove('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add to cart functionality
|
||||
document.querySelectorAll('.add-to-cart-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const productId = this.dataset.productId;
|
||||
const productName = this.dataset.productName;
|
||||
|
||||
try {
|
||||
// Show loading state
|
||||
window.setButtonLoading(this, '<i class="bi bi-hourglass-split me-1"></i>Adding...');
|
||||
|
||||
// Add to cart API call using apiJson
|
||||
const response = await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
product_id: productId,
|
||||
quantity: 1
|
||||
})
|
||||
});
|
||||
|
||||
// Show success message
|
||||
window.setButtonSuccess(this, '<i class="bi bi-check-circle me-1"></i>Added!', 2000);
|
||||
|
||||
// Update cart count in header
|
||||
if (window.updateCartCount) {
|
||||
window.updateCartCount();
|
||||
}
|
||||
|
||||
// Notify other listeners about cart update
|
||||
try {
|
||||
const meta = (response && response.metadata) ? response.metadata : {};
|
||||
if (typeof window.emitCartUpdated === 'function') {
|
||||
window.emitCartUpdated(meta.cart_count);
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('cartUpdated event dispatch failed:', e);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error adding to cart:', error);
|
||||
window.handleApiError(error, 'adding item to cart', this);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
365
src/static/js/services.js
Normal file
365
src/static/js/services.js
Normal file
@@ -0,0 +1,365 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function parseHydrationData() {
|
||||
try {
|
||||
const el = document.getElementById('services-data');
|
||||
if (!el) return {};
|
||||
const txt = el.textContent || '{}';
|
||||
return JSON.parse(txt);
|
||||
} catch (e) {
|
||||
console.debug('services hydration parse failed:', e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function qs(selector, root = document) {
|
||||
return root.querySelector(selector);
|
||||
}
|
||||
|
||||
function qsa(selector, root = document) {
|
||||
return Array.from(root.querySelectorAll(selector));
|
||||
}
|
||||
|
||||
function showAuthenticationModal(message) {
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="authModal" tabindex="-1" aria-labelledby="authModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="authModalLabel">
|
||||
<i class="bi bi-lock me-2"></i>Authentication Required
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div class="mb-3">
|
||||
<i class="bi bi-person-circle text-primary" style="font-size: 3rem;"></i>
|
||||
</div>
|
||||
<p class="mb-3">${message}</p>
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||
<a href="/login" class="btn btn-primary me-md-2">
|
||||
<i class="bi bi-box-arrow-in-right me-2"></i>Log In
|
||||
</a>
|
||||
<a href="/register" class="btn btn-outline-primary">
|
||||
<i class="bi bi-person-plus me-2"></i>Register
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const existing = document.getElementById('authModal');
|
||||
if (existing) existing.remove();
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modal = new bootstrap.Modal(document.getElementById('authModal'));
|
||||
modal.show();
|
||||
document.getElementById('authModal').addEventListener('hidden.bs.modal', function () {
|
||||
this.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function showNotification(message, type = 'info') {
|
||||
const notification = document.createElement('div');
|
||||
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||
notification.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999; min-width: 300px;';
|
||||
notification.innerHTML = `${message}<button type="button" class="btn-close" data-bs-dismiss="alert"></button>`;
|
||||
document.body.appendChild(notification);
|
||||
setTimeout(() => {
|
||||
if (notification.parentNode) notification.remove();
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function bindAddToCart(btn) {
|
||||
if (!btn || btn.dataset.bound === '1') return;
|
||||
btn.dataset.bound = '1';
|
||||
|
||||
btn.addEventListener('click', async function () {
|
||||
const productId = this.dataset.productId;
|
||||
const originalText = this.innerHTML;
|
||||
|
||||
this.innerHTML = '<i class="bi bi-hourglass-split me-1"></i>Booking...';
|
||||
this.disabled = true;
|
||||
|
||||
try {
|
||||
await window.apiJson('/api/cart/add', {
|
||||
method: 'POST',
|
||||
body: { product_id: productId, quantity: 1 },
|
||||
});
|
||||
|
||||
this.innerHTML = '<i class="bi bi-check-circle me-1"></i>Added!';
|
||||
this.classList.remove('btn-primary');
|
||||
this.classList.add('btn-success');
|
||||
|
||||
if (typeof window.updateCartCount === 'function') {
|
||||
try { window.updateCartCount(); } catch (_) {}
|
||||
}
|
||||
try {
|
||||
if (typeof window.emitCartUpdated === 'function') window.emitCartUpdated();
|
||||
} catch (e) {
|
||||
console.debug('cartUpdated event dispatch failed:', e);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
this.innerHTML = originalText;
|
||||
this.classList.remove('btn-success');
|
||||
this.classList.add('btn-primary');
|
||||
this.disabled = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
// 401: require authentication
|
||||
if (error && error.status === 401) {
|
||||
showAuthenticationModal('Make sure to register or log in to continue');
|
||||
this.innerHTML = originalText;
|
||||
this.disabled = false;
|
||||
return;
|
||||
}
|
||||
// 402: insufficient funds handled globally by interceptor
|
||||
if (error && error.status === 402) {
|
||||
this.innerHTML = originalText;
|
||||
this.disabled = false;
|
||||
return;
|
||||
}
|
||||
console.error('Error adding to cart:', error);
|
||||
this.innerHTML = '<i class="bi bi-exclamation-triangle me-1"></i>Error';
|
||||
this.classList.remove('btn-primary');
|
||||
this.classList.add('btn-danger');
|
||||
setTimeout(() => {
|
||||
this.innerHTML = originalText;
|
||||
this.classList.remove('btn-danger');
|
||||
this.classList.add('btn-primary');
|
||||
this.disabled = false;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generateStarRating(rating) {
|
||||
const full = Math.floor(rating || 0);
|
||||
const half = (rating || 0) % 1 !== 0;
|
||||
const empty = 5 - full - (half ? 1 : 0);
|
||||
let html = '';
|
||||
for (let i = 0; i < full; i++) html += '<i class="bi bi-star-fill text-warning"></i>';
|
||||
if (half) html += '<i class="bi bi-star-half text-warning"></i>';
|
||||
for (let i = 0; i < empty; i++) html += '<i class="bi bi-star text-muted"></i>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function createServiceCard(service) {
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-lg-6 mb-4';
|
||||
|
||||
const priceDisplay = service.pricing_type === 'hourly'
|
||||
? `$${service.price_per_hour}/hour`
|
||||
: `$${service.price_per_hour}`;
|
||||
|
||||
const skillsDisplay = Array.isArray(service.skills)
|
||||
? service.skills.slice(0, 3).map(skill => `<span class="badge bg-light text-dark me-1 mb-1">${skill}</span>`).join('')
|
||||
: '';
|
||||
|
||||
const ratingStars = generateStarRating(service.rating);
|
||||
|
||||
col.innerHTML = `
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div class="service-icon me-3">
|
||||
<i class="bi bi-gear-fill fs-2 text-primary"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h5 class="card-title mb-1">
|
||||
<a href="#" class="text-decoration-none text-dark">${service.name}</a>
|
||||
</h5>
|
||||
<span class="badge bg-success">Available</span>
|
||||
</div>
|
||||
<div class="text-muted small mb-2">
|
||||
<i class="bi bi-building me-1"></i>${service.provider_name}
|
||||
<span class="ms-2"><i class="bi bi-geo-alt me-1"></i>Remote</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-text">${service.description}</p>
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">Service Details:</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="service-detail"><i class="bi bi-star me-2"></i><span>Level: ${service.experience_level}</span></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="service-detail"><i class="bi bi-reply me-2"></i><span>Response: ${service.response_time}h</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
${skillsDisplay ? `
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-2">Skills:</h6>
|
||||
<div class="d-flex flex-wrap">${skillsDisplay}</div>
|
||||
</div>` : ''}
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="me-2">Rating:</span>
|
||||
${ratingStars}
|
||||
<span class="ms-2 text-muted small">(New Service)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="price-info">
|
||||
<div class="fw-bold text-primary fs-5">${priceDisplay}</div>
|
||||
<small class="text-muted">per engagement</small>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-primary btn-sm contact-btn"><i class="bi bi-envelope me-1"></i>Contact</button>
|
||||
<button class="btn btn-outline-primary btn-sm view-details-btn">View Details</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const contactBtn = col.querySelector('.contact-btn');
|
||||
const detailsBtn = col.querySelector('.view-details-btn');
|
||||
contactBtn.addEventListener('click', () => contactServiceProvider(service.id, service.name));
|
||||
detailsBtn.addEventListener('click', () => viewServiceDetails(service.id));
|
||||
return col;
|
||||
}
|
||||
|
||||
function displaySessionServices(services) {
|
||||
const grid = document.getElementById('services-grid') || qs('.row');
|
||||
if (!grid) return;
|
||||
|
||||
const existingEmptyState = grid.querySelector('.col-12 .text-center');
|
||||
if (existingEmptyState) existingEmptyState.parentElement.remove();
|
||||
|
||||
services.forEach(service => {
|
||||
const card = createServiceCard(service);
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function convertUserServiceToMarketplace(service) {
|
||||
return {
|
||||
id: `marketplace-service-${service.id}`,
|
||||
source_service_id: service.id,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
category: service.category || 'Professional Services',
|
||||
provider_name: service.provider_name || 'Service Provider',
|
||||
price_per_hour: service.price_amount || service.hourly_rate || service.price_per_hour || 0,
|
||||
pricing_type: service.pricing_type || 'hourly',
|
||||
experience_level: service.experience_level || 'intermediate',
|
||||
response_time: service.response_time || 24,
|
||||
skills: service.skills || [],
|
||||
rating: service.rating || 0,
|
||||
status: service.status || 'Active',
|
||||
availability: service.status === 'Active' ? 'Available' : 'Unavailable',
|
||||
created_at: service.created_at || new Date().toISOString(),
|
||||
featured: service.featured || false,
|
||||
metadata: service.metadata || {
|
||||
tags: service.skills || [],
|
||||
location: 'Remote',
|
||||
rating: service.rating || 0,
|
||||
review_count: 0
|
||||
},
|
||||
attributes: service.attributes || {
|
||||
duration_hours: { value: service.available_hours || 0 },
|
||||
expertise_level: { value: service.experience_level || 'intermediate' },
|
||||
response_time_hours: { value: service.response_time || 24 },
|
||||
support_type: { value: service.delivery_method || 'remote' }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function convertProductToMarketplace(product) {
|
||||
return {
|
||||
id: product.id,
|
||||
source_product_id: product.id,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
category: product.category_id || 'Application',
|
||||
provider_name: product.provider_name || 'Service Provider',
|
||||
price_per_hour: product.base_price || 0,
|
||||
pricing_type: 'fixed',
|
||||
experience_level: product.attributes?.experience_level?.value || 'intermediate',
|
||||
response_time: product.attributes?.response_time?.value || 24,
|
||||
skills: product.metadata?.tags || [],
|
||||
rating: product.metadata?.rating || 0,
|
||||
status: product.availability === 'Available' ? 'Active' : 'Inactive',
|
||||
availability: product.availability || 'Available',
|
||||
created_at: product.created_at || new Date().toISOString(),
|
||||
featured: product.metadata?.featured || false,
|
||||
metadata: {
|
||||
tags: product.metadata?.tags || [],
|
||||
location: product.metadata?.location || 'Remote',
|
||||
rating: product.metadata?.rating || 0,
|
||||
review_count: product.metadata?.review_count || 0
|
||||
},
|
||||
attributes: product.attributes || {
|
||||
delivery_method: { value: 'remote' },
|
||||
pricing_type: { value: 'fixed' },
|
||||
experience_level: { value: 'intermediate' },
|
||||
response_time_hours: { value: 24 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function contactServiceProvider(serviceId, serviceName) {
|
||||
showNotification(`Contacting service provider for ${serviceName}...`, 'info');
|
||||
}
|
||||
|
||||
function viewServiceDetails(serviceId) {
|
||||
showNotification('Loading service details...', 'info');
|
||||
}
|
||||
|
||||
async function loadSessionStorageServices() {
|
||||
// NOTE: For marketplace services page, services are already rendered server-side
|
||||
// We just need to bind to existing buttons and optionally load additional user services
|
||||
// The backend marketplace controller already aggregates all users' public services
|
||||
|
||||
try {
|
||||
// Only attempt to load additional session storage services as fallback
|
||||
const marketplaceServices = JSON.parse(sessionStorage.getItem('marketplaceServices') || '[]');
|
||||
const userServices = JSON.parse(sessionStorage.getItem('userServices') || '[]');
|
||||
|
||||
const allSessionServices = [...marketplaceServices];
|
||||
userServices.forEach(userService => {
|
||||
const existsInMarketplace = marketplaceServices.some(ms => ms.source_service_id === userService.id);
|
||||
if (!existsInMarketplace && userService.status === 'Active') {
|
||||
allSessionServices.push(convertUserServiceToMarketplace(userService));
|
||||
}
|
||||
});
|
||||
|
||||
if (allSessionServices.length > 0) {
|
||||
displaySessionServices(allSessionServices);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Could not load services from session storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function bindExistingSSRButtons() {
|
||||
qsa('.add-to-cart-btn').forEach(bindAddToCart);
|
||||
}
|
||||
|
||||
function listenForServiceCreated() {
|
||||
window.addEventListener('serviceCreated', function (event) {
|
||||
console.log('New service created, refreshing marketplace:', event.detail);
|
||||
setTimeout(() => { loadSessionStorageServices(); }, 500);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
parseHydrationData();
|
||||
bindExistingSSRButtons();
|
||||
listenForServiceCreated();
|
||||
loadSessionStorageServices();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
38
src/static/js/slice-rental-form.js
Normal file
38
src/static/js/slice-rental-form.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Slice rental form functionality
|
||||
* Handles form submission with apiJson
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('sliceRentalForm');
|
||||
if (!form) return;
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
const formData = new FormData(this);
|
||||
|
||||
try {
|
||||
window.setButtonLoading(submitBtn, 'Processing...');
|
||||
|
||||
// Submit form using apiJson
|
||||
const response = await window.apiJson('/marketplace/slice/rent', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
window.setButtonSuccess(submitBtn, 'Success!', 2000);
|
||||
window.showSuccessToast('Slice rental request submitted successfully');
|
||||
|
||||
// Reset form after successful submission
|
||||
setTimeout(() => {
|
||||
this.reset();
|
||||
}, 1000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error submitting slice rental form:', error);
|
||||
window.handleApiError(error, 'submitting slice rental request', submitBtn);
|
||||
}
|
||||
});
|
||||
});
|
94
src/static/js/slice-rental.js
Normal file
94
src/static/js/slice-rental.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Slice rental form functionality
|
||||
* Migrated from inline scripts to use apiJson and shared error handlers
|
||||
*/
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Get slice data from JSON hydration
|
||||
const sliceDataElement = document.getElementById('slice-data');
|
||||
let sliceData = {};
|
||||
|
||||
if (sliceDataElement) {
|
||||
try {
|
||||
sliceData = JSON.parse(sliceDataElement.textContent);
|
||||
} catch (error) {
|
||||
console.error('Error parsing slice data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const vmRadio = document.getElementById('vm_deployment');
|
||||
const k8sRadio = document.getElementById('k8s_deployment');
|
||||
const vmOptions = document.getElementById('vm_options');
|
||||
const k8sOptions = document.getElementById('k8s_options');
|
||||
const totalPriceElement = document.getElementById('total_price');
|
||||
const quantityInput = document.getElementById('quantity');
|
||||
const durationSelect = document.getElementById('duration');
|
||||
|
||||
// Show/hide deployment options based on selection
|
||||
function toggleDeploymentOptions() {
|
||||
if (vmRadio && vmRadio.checked) {
|
||||
vmOptions?.classList.remove('d-none');
|
||||
k8sOptions?.classList.add('d-none');
|
||||
} else if (k8sRadio && k8sRadio.checked) {
|
||||
vmOptions?.classList.add('d-none');
|
||||
k8sOptions?.classList.remove('d-none');
|
||||
}
|
||||
updateTotalPrice();
|
||||
}
|
||||
|
||||
// Update total price calculation
|
||||
function updateTotalPrice() {
|
||||
const basePrice = sliceData.basePrice || 0;
|
||||
const quantity = parseInt(quantityInput?.value) || 1;
|
||||
const duration = parseInt(durationSelect?.value) || 1;
|
||||
|
||||
const totalPrice = basePrice * quantity * duration;
|
||||
|
||||
if (totalPriceElement) {
|
||||
totalPriceElement.textContent = `$${totalPrice.toFixed(2)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
if (vmRadio) vmRadio.addEventListener('change', toggleDeploymentOptions);
|
||||
if (k8sRadio) k8sRadio.addEventListener('change', toggleDeploymentOptions);
|
||||
if (quantityInput) quantityInput.addEventListener('input', updateTotalPrice);
|
||||
if (durationSelect) durationSelect.addEventListener('change', updateTotalPrice);
|
||||
|
||||
// Initialize
|
||||
toggleDeploymentOptions();
|
||||
updateTotalPrice();
|
||||
|
||||
// Form submission
|
||||
const rentalForm = document.getElementById('slice-rental-form');
|
||||
if (rentalForm) {
|
||||
rentalForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const submitBtn = this.querySelector('button[type="submit"]');
|
||||
if (submitBtn) {
|
||||
setButtonLoading(submitBtn, 'Processing...');
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData(this);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
await window.apiJson('/api/slice-rental', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
showSuccessToast('Slice rental request submitted successfully');
|
||||
|
||||
// Redirect to dashboard or confirmation page
|
||||
setTimeout(() => {
|
||||
window.location.href = '/dashboard/user';
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
handleApiError(error, 'submitting rental request', submitBtn);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
370
src/static/js/statistics.js
Normal file
370
src/static/js/statistics.js
Normal file
@@ -0,0 +1,370 @@
|
||||
(function () {
|
||||
function getHydrationData(id) {
|
||||
try {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return {};
|
||||
const text = el.textContent || el.innerText || '{}';
|
||||
return JSON.parse(text || '{}');
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse hydration data for', id, e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getCtx(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return null;
|
||||
const ctx = el.getContext ? el.getContext('2d') : null;
|
||||
return ctx || null;
|
||||
}
|
||||
|
||||
function makeChart(ctx, cfg) {
|
||||
if (!ctx || typeof Chart === 'undefined') return null;
|
||||
return new Chart(ctx, cfg);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Global defaults
|
||||
if (typeof Chart !== 'undefined') {
|
||||
Chart.defaults.font.family = "'Poppins', 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif";
|
||||
Chart.defaults.font.size = 12;
|
||||
Chart.defaults.responsive = true;
|
||||
Chart.defaults.maintainAspectRatio = false;
|
||||
}
|
||||
|
||||
const data = getHydrationData('statistics-data') || {};
|
||||
|
||||
// Helpers to pull arrays with defaults
|
||||
const pick = (obj, path, fallback) => {
|
||||
try {
|
||||
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj) ?? fallback;
|
||||
} catch (_) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
// Resource Distribution (Doughnut)
|
||||
makeChart(getCtx('resourceDistributionChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: pick(data, 'resourceDistribution.labels', ['Compute', 'Storage', 'Network', 'Specialized']),
|
||||
datasets: [{
|
||||
data: pick(data, 'resourceDistribution.values', [45, 25, 20, 10]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Resource Type Distribution' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Monthly Growth (Line)
|
||||
makeChart(getCtx('monthlyGrowthChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'monthlyGrowth.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Compute Resources',
|
||||
data: pick(data, 'monthlyGrowth.compute', [120, 150, 180, 210, 250, 280]),
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: '3Nodes',
|
||||
data: pick(data, 'monthlyGrowth.nodes', [45, 60, 75, 90, 120, 150]),
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Applications',
|
||||
data: pick(data, 'monthlyGrowth.apps', [30, 40, 50, 60, 70, 80]),
|
||||
borderColor: '#ffc107',
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: 'Monthly Growth by Category' },
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, title: { display: true, text: 'Number of Resources' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// CPU Utilization (Bar)
|
||||
makeChart(getCtx('cpuUtilizationChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pick(data, 'cpuUtilization.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
|
||||
datasets: [{
|
||||
label: 'Average CPU Utilization (%)',
|
||||
data: pick(data, 'cpuUtilization.values', [75, 68, 82, 60, 65, 72]),
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.7)',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
title: { display: true, text: 'Average CPU Utilization by Region' },
|
||||
},
|
||||
scales: { y: { beginAtZero: true, max: 100, title: { display: true, text: 'Utilization %' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Memory Allocation (Pie)
|
||||
makeChart(getCtx('memoryAllocationChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: pick(data, 'memoryAllocation.labels', ['2GB', '4GB', '8GB', '16GB', '32GB+']),
|
||||
datasets: [{
|
||||
data: pick(data, 'memoryAllocation.values', [15, 25, 30, 20, 10]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Memory Size Distribution' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Storage Distribution (Pie)
|
||||
makeChart(getCtx('storageDistributionChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: pick(data, 'storageDistribution.labels', ['SSD', 'HDD', 'Hybrid', 'Object Storage']),
|
||||
datasets: [{
|
||||
data: pick(data, 'storageDistribution.values', [45, 30, 15, 10]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Storage Type Distribution' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Resource Pricing (Line)
|
||||
makeChart(getCtx('resourcePricingChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'resourcePricing.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [
|
||||
{ label: 'Compute (per vCPU)', data: pick(data, 'resourcePricing.compute', [50, 48, 45, 42, 40, 38]), borderColor: '#007bff', tension: 0.3 },
|
||||
{ label: 'Memory (per GB)', data: pick(data, 'resourcePricing.memory', [25, 24, 22, 20, 19, 18]), borderColor: '#28a745', tension: 0.3 },
|
||||
{ label: 'Storage (per 10GB)', data: pick(data, 'resourcePricing.storage', [15, 14, 13, 12, 11, 10]), borderColor: '#ffc107', tension: 0.3 },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
title: { display: true, text: 'Resource Pricing Trends ($)' },
|
||||
},
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Price ($)' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Node Geographic (Bar)
|
||||
makeChart(getCtx('nodeGeographicChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pick(data, 'nodeGeographic.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
|
||||
datasets: [{
|
||||
label: 'Number of 3Nodes',
|
||||
data: pick(data, 'nodeGeographic.values', [45, 32, 20, 15, 8, 5]),
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.7)',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Geographic Distribution of 3Nodes' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Number of Nodes' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Node Types (Doughnut)
|
||||
makeChart(getCtx('nodeTypesChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: pick(data, 'nodeTypes.labels', ['Basic', 'Standard', 'Advanced', 'Enterprise']),
|
||||
datasets: [{
|
||||
data: pick(data, 'nodeTypes.values', [20, 40, 30, 10]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Node Types Distribution' } },
|
||||
},
|
||||
});
|
||||
|
||||
// Node Uptime (Line)
|
||||
makeChart(getCtx('nodeUptimeChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'nodeUptime.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [{
|
||||
label: 'Average Uptime (%)',
|
||||
data: pick(data, 'nodeUptime.values', [98.5, 99.1, 99.3, 99.5, 99.6, 99.8]),
|
||||
borderColor: '#28a745',
|
||||
backgroundColor: 'rgba(40, 167, 69, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: '3Node Uptime Performance' } },
|
||||
scales: { y: { min: 95, max: 100, title: { display: true, text: 'Uptime %' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Node Certification (Line)
|
||||
makeChart(getCtx('nodeCertificationChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'nodeCertification.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [{
|
||||
label: 'Certification Rate (%)',
|
||||
data: pick(data, 'nodeCertification.values', [70, 75, 80, 85, 88, 92]),
|
||||
borderColor: '#ffc107',
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: '3Node Certification Rate' } },
|
||||
scales: { y: { min: 50, max: 100, title: { display: true, text: 'Certification %' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Gateway Traffic (Line)
|
||||
makeChart(getCtx('gatewayTrafficChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'gatewayTraffic.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [{
|
||||
label: 'Traffic (TB)',
|
||||
data: pick(data, 'gatewayTraffic.values', [25, 32, 40, 50, 65, 75]),
|
||||
borderColor: '#17a2b8',
|
||||
backgroundColor: 'rgba(23, 162, 184, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Monthly Gateway Traffic' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Traffic (TB)' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Gateway Availability (Bar)
|
||||
makeChart(getCtx('gatewayAvailabilityChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pick(data, 'gatewayAvailability.labels', ['Europe', 'North America', 'Asia', 'Africa', 'South America', 'Oceania']),
|
||||
datasets: [{
|
||||
label: 'Availability (%)',
|
||||
data: pick(data, 'gatewayAvailability.values', [99.8, 99.7, 99.5, 99.2, 99.0, 99.6]),
|
||||
backgroundColor: 'rgba(23, 162, 184, 0.7)',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Gateway Availability by Region' } },
|
||||
scales: { y: { min: 98, max: 100, title: { display: true, text: 'Availability %' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// App Categories (Doughnut)
|
||||
makeChart(getCtx('appCategoriesChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: pick(data, 'appCategories.labels', ['Web Applications', 'Databases', 'Developer Tools', 'Collaboration', 'Storage', 'Other']),
|
||||
datasets: [{
|
||||
data: pick(data, 'appCategories.values', [30, 25, 15, 12, 10, 8]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545', '#6c757d'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Application Categories' } },
|
||||
},
|
||||
});
|
||||
|
||||
// App Deployment (Line)
|
||||
makeChart(getCtx('appDeploymentChart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: pick(data, 'appDeployment.labels', ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']),
|
||||
datasets: [{
|
||||
label: 'Monthly Deployments',
|
||||
data: pick(data, 'appDeployment.values', [35, 42, 50, 65, 80, 95]),
|
||||
borderColor: '#ffc107',
|
||||
backgroundColor: 'rgba(255, 193, 7, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Application Deployment Trends' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Number of Deployments' } } },
|
||||
},
|
||||
});
|
||||
|
||||
// Service Categories (Doughnut)
|
||||
makeChart(getCtx('serviceCategoriesChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: pick(data, 'serviceCategories.labels', ['System Administration', 'Development', 'Migration', 'Consulting', 'Training', 'Other']),
|
||||
datasets: [{
|
||||
data: pick(data, 'serviceCategories.values', [35, 25, 15, 10, 10, 5]),
|
||||
backgroundColor: ['#007bff', '#28a745', '#17a2b8', '#ffc107', '#dc3545', '#6c757d'],
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { position: 'right' }, title: { display: true, text: 'Service Categories' } },
|
||||
},
|
||||
});
|
||||
|
||||
// Service Rates (Bar)
|
||||
makeChart(getCtx('serviceRatesChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: pick(data, 'serviceRates.labels', ['System Admin', 'Development', 'Migration', 'Consulting', 'Training']),
|
||||
datasets: [{
|
||||
label: 'Average Rate ($/hour)',
|
||||
data: pick(data, 'serviceRates.values', [50, 75, 65, 85, 60]),
|
||||
backgroundColor: 'rgba(0, 123, 255, 0.7)',
|
||||
borderColor: 'rgba(0, 123, 255, 1)',
|
||||
borderWidth: 1,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false }, title: { display: true, text: 'Average Service Rates' } },
|
||||
scales: { y: { beginAtZero: true, title: { display: true, text: 'Rate ($/hour)' } } },
|
||||
},
|
||||
});
|
||||
});
|
||||
})();
|
160
src/static/js/user-database.js
Normal file
160
src/static/js/user-database.js
Normal file
@@ -0,0 +1,160 @@
|
||||
// User Database Simulation
|
||||
// This file simulates a user database with realistic user profiles
|
||||
|
||||
class UserDatabase {
|
||||
constructor() {
|
||||
this.initializeUsers();
|
||||
}
|
||||
|
||||
initializeUsers() {
|
||||
// Initialize mock users if not already in session storage
|
||||
if (!sessionStorage.getItem('userDatabase')) {
|
||||
const mockUsers = [
|
||||
{
|
||||
id: 'user-001',
|
||||
username: 'sara_farmer',
|
||||
display_name: 'Sara Nicks',
|
||||
email: 'user1@example.com',
|
||||
password: 'password',
|
||||
role: 'farmer',
|
||||
location: 'Amsterdam, Netherlands',
|
||||
joined_date: '2024-01-15',
|
||||
reputation: 4.8,
|
||||
verified: true,
|
||||
stats: {
|
||||
nodes_operated: 5,
|
||||
total_uptime: 99.7,
|
||||
earnings_total: 2450
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user-002',
|
||||
username: 'alex_dev',
|
||||
display_name: 'Alex Thompson',
|
||||
email: 'user2@example.com',
|
||||
password: 'password',
|
||||
role: 'app_provider',
|
||||
location: 'Berlin, Germany',
|
||||
joined_date: '2024-02-20',
|
||||
reputation: 4.9,
|
||||
verified: true,
|
||||
stats: {
|
||||
apps_published: 3,
|
||||
total_deployments: 150,
|
||||
revenue_total: 3200
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user-003',
|
||||
username: 'mike_consultant',
|
||||
display_name: 'Mike Rodriguez',
|
||||
email: 'user3@example.com',
|
||||
password: 'password',
|
||||
role: 'service_provider',
|
||||
location: 'New York, USA',
|
||||
joined_date: '2024-01-10',
|
||||
reputation: 4.7,
|
||||
verified: true,
|
||||
stats: {
|
||||
services_offered: 4,
|
||||
clients_served: 25,
|
||||
hours_completed: 340
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user-004',
|
||||
username: 'emma_security',
|
||||
display_name: 'Emma Wilson',
|
||||
email: 'user4@example.com',
|
||||
password: 'password',
|
||||
role: 'service_provider',
|
||||
location: 'London, UK',
|
||||
joined_date: '2024-03-05',
|
||||
reputation: 4.8,
|
||||
verified: true,
|
||||
stats: {
|
||||
services_offered: 2,
|
||||
clients_served: 18,
|
||||
hours_completed: 220
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'user-005',
|
||||
username: 'jordan_multi',
|
||||
display_name: 'Jordan Mitchell',
|
||||
email: 'user5@example.com',
|
||||
password: 'password',
|
||||
role: 'multi', // Can be farmer, app_provider, service_provider, user
|
||||
location: 'Toronto, Canada',
|
||||
joined_date: new Date().toISOString().split('T')[0],
|
||||
reputation: 5.0,
|
||||
verified: true,
|
||||
stats: {
|
||||
nodes_operated: 2,
|
||||
apps_published: 1,
|
||||
services_offered: 1,
|
||||
deployments: 5
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
sessionStorage.setItem('userDatabase', JSON.stringify(mockUsers));
|
||||
}
|
||||
}
|
||||
|
||||
getUser(userId) {
|
||||
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
|
||||
return users.find(user => user.id === userId);
|
||||
}
|
||||
|
||||
getUserByUsername(username) {
|
||||
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
|
||||
return users.find(user => user.username === username);
|
||||
}
|
||||
|
||||
getAllUsers() {
|
||||
return JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
|
||||
}
|
||||
|
||||
updateUserStats(userId, statUpdates) {
|
||||
const users = JSON.parse(sessionStorage.getItem('userDatabase') || '[]');
|
||||
const userIndex = users.findIndex(user => user.id === userId);
|
||||
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex].stats = { ...users[userIndex].stats, ...statUpdates };
|
||||
sessionStorage.setItem('userDatabase', JSON.stringify(users));
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentUser() {
|
||||
return this.getUser('user-005'); // Current user
|
||||
}
|
||||
|
||||
getUsersByRole(role) {
|
||||
const users = this.getAllUsers();
|
||||
return users.filter(user => user.role === role || user.role === 'multi');
|
||||
}
|
||||
|
||||
authenticateUser(email, password) {
|
||||
const users = this.getAllUsers();
|
||||
const user = users.find(user => user.email === email && user.password === password);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
validateCredentials(email, password) {
|
||||
return this.authenticateUser(email, password) !== null;
|
||||
}
|
||||
|
||||
getUserByEmail(email) {
|
||||
const users = this.getAllUsers();
|
||||
return users.find(user => user.email === email);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize user database when script loads
|
||||
const userDB = new UserDatabase();
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = UserDatabase;
|
||||
}
|
97
src/static/js/utils/errors.js
Normal file
97
src/static/js/utils/errors.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/* eslint-disable no-console */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function parseJsonSafe(text) {
|
||||
try { return JSON.parse(text); } catch (_) { return null; }
|
||||
}
|
||||
|
||||
function isInsufficientFundsEnvelope(obj) {
|
||||
if (!obj || typeof obj !== 'object') return false;
|
||||
const envelope = obj.error || (obj.data && obj.data.error) || null;
|
||||
if (!envelope || typeof envelope !== 'object') return false;
|
||||
const code = (envelope.code || '').toString();
|
||||
return code.toUpperCase() === 'INSUFFICIENT_FUNDS';
|
||||
}
|
||||
|
||||
function extractDetails(obj) {
|
||||
const envelope = obj.error || (obj.data && obj.data.error) || {};
|
||||
const details = envelope.details || {};
|
||||
const currency = details.currency || 'USD';
|
||||
const balance = Number(details.wallet_balance_usd || details.wallet_balance || 0);
|
||||
const required = Number(details.required_usd || details.required || 0);
|
||||
const deficit = Number(details.deficit_usd || details.deficit || Math.max(required - balance, 0));
|
||||
return { currency, balance, required, deficit, message: envelope.message || 'Insufficient balance' };
|
||||
}
|
||||
|
||||
function formatMoney(amount, currency) {
|
||||
const n = Number(amount);
|
||||
const c = (currency || 'USD').toUpperCase();
|
||||
if (!isFinite(n)) return `${amount} ${c}`;
|
||||
return `${n.toFixed(2)} ${c}`;
|
||||
}
|
||||
|
||||
function renderInsufficientFunds(details) {
|
||||
const { currency, balance, required, deficit, message } = details || {};
|
||||
const bodyHtml = `
|
||||
<div class="mb-2">${message || 'Insufficient balance'}</div>
|
||||
<ul class="list-unstyled mb-3">
|
||||
<li><strong>Wallet balance:</strong> ${formatMoney(balance, currency)}</li>
|
||||
<li><strong>Required:</strong> ${formatMoney(required, currency)}</li>
|
||||
<li><strong>Short by:</strong> <span class="text-danger">${formatMoney(deficit, currency)}</span></li>
|
||||
</ul>
|
||||
<div class="small text-muted">You can add credits to your wallet and try again.</div>
|
||||
`;
|
||||
|
||||
if (window.modalSystem && typeof window.modalSystem.showModal === 'function') {
|
||||
window.modalSystem.showModal('insufficient-funds-modal', {
|
||||
title: 'Insufficient Funds',
|
||||
message: bodyHtml,
|
||||
type: 'warning',
|
||||
confirmText: 'Add Credits',
|
||||
cancelText: 'Cancel',
|
||||
showCancel: true,
|
||||
onConfirm: function () { window.location.href = '/dashboard/wallet?action=topup'; }
|
||||
});
|
||||
} else {
|
||||
// Fallback to alert
|
||||
alert(`Insufficient funds. Balance: ${formatMoney(balance, currency)}. Required: ${formatMoney(required, currency)}. Short by: ${formatMoney(deficit, currency)}.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInsufficientFundsResponse(response, preReadText) {
|
||||
try {
|
||||
if (!response || response.status !== 402) return false;
|
||||
const text = typeof preReadText === 'string' ? preReadText : await response.text();
|
||||
const json = parseJsonSafe(text) || {};
|
||||
if (!isInsufficientFundsEnvelope(json)) return false;
|
||||
const details = extractDetails(json);
|
||||
renderInsufficientFunds(details);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to render insufficient funds response:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsufficientFundsPayload(status, json) {
|
||||
try {
|
||||
if (Number(status) !== 402) return false;
|
||||
if (!isInsufficientFundsEnvelope(json)) return false;
|
||||
const details = extractDetails(json || {});
|
||||
renderInsufficientFunds(details);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to render insufficient funds payload:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose API
|
||||
window.Errors = window.Errors || {};
|
||||
window.Errors.isInsufficientFundsEnvelope = isInsufficientFundsEnvelope;
|
||||
window.Errors.extractInsufficientFundsDetails = extractDetails;
|
||||
window.Errors.renderInsufficientFunds = renderInsufficientFunds;
|
||||
window.Errors.handleInsufficientFundsResponse = handleInsufficientFundsResponse;
|
||||
window.Errors.handleInsufficientFundsPayload = handleInsufficientFundsPayload;
|
||||
})();
|
131
src/static/js/utils/shared-handlers.js
Normal file
131
src/static/js/utils/shared-handlers.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Shared error handler utility for consistent toast messaging
|
||||
* Part of the apiJson migration - standardizes error handling across the marketplace
|
||||
*/
|
||||
|
||||
/**
|
||||
* Shows a standardized error toast message
|
||||
* @param {string} message - Error message to display
|
||||
* @param {string} title - Optional title for the toast (default: "Error")
|
||||
*/
|
||||
function showErrorToast(message, title = "Error") {
|
||||
// Use existing toast system from base.js if available
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, 'error');
|
||||
} else if (typeof showNotification === 'function') {
|
||||
showNotification(message, 'error');
|
||||
} else {
|
||||
// Fallback to console if no toast system available
|
||||
console.error(`${title}: ${message}`);
|
||||
alert(`${title}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a standardized success toast message
|
||||
* @param {string} message - Success message to display
|
||||
* @param {string} title - Optional title for the toast (default: "Success")
|
||||
*/
|
||||
function showSuccessToast(message, title = "Success") {
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message, 'success');
|
||||
} else if (typeof showNotification === 'function') {
|
||||
showNotification(message, 'success');
|
||||
} else {
|
||||
console.log(`${title}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles authentication errors consistently
|
||||
* Redirects to login or shows authentication modal
|
||||
*/
|
||||
function handleAuthenticationError() {
|
||||
// Check if authentication modal is available
|
||||
if (typeof showAuthenticationModal === 'function') {
|
||||
showAuthenticationModal();
|
||||
} else {
|
||||
// Redirect to login with current page as return URL
|
||||
const returnUrl = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
window.location.href = `/login?return=${returnUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized API error handler for apiJson responses
|
||||
* @param {Error} error - The error thrown by apiJson
|
||||
* @param {string} context - Context of the operation (e.g., "adding to cart")
|
||||
* @param {HTMLElement} buttonElement - Optional button to reset state
|
||||
*/
|
||||
function handleApiError(error, context, buttonElement = null) {
|
||||
console.error(`Error ${context}:`, error);
|
||||
|
||||
// Reset button state if provided
|
||||
if (buttonElement) {
|
||||
buttonElement.disabled = false;
|
||||
if (buttonElement.dataset.originalText) {
|
||||
buttonElement.innerHTML = buttonElement.dataset.originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle specific error types
|
||||
if (error.message && error.message.includes('authentication')) {
|
||||
handleAuthenticationError();
|
||||
return;
|
||||
}
|
||||
|
||||
// Show error message
|
||||
const message = error.message || `Failed ${context}`;
|
||||
showErrorToast(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets button loading state with spinner
|
||||
* @param {HTMLElement} button - Button element
|
||||
* @param {string} loadingText - Text to show while loading
|
||||
*/
|
||||
function setButtonLoading(button, loadingText) {
|
||||
if (!button.dataset.originalText) {
|
||||
button.dataset.originalText = button.innerHTML;
|
||||
}
|
||||
button.disabled = true;
|
||||
button.innerHTML = `<i class="bi bi-hourglass-split me-1"></i>${loadingText}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets button to original state
|
||||
* @param {HTMLElement} button - Button element
|
||||
*/
|
||||
function resetButton(button) {
|
||||
button.disabled = false;
|
||||
if (button.dataset.originalText) {
|
||||
button.innerHTML = button.dataset.originalText;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets button success state temporarily
|
||||
* @param {HTMLElement} button - Button element
|
||||
* @param {string} successText - Text to show on success
|
||||
* @param {number} resetDelay - Delay before resetting (default: 2000ms)
|
||||
*/
|
||||
function setButtonSuccess(button, successText, resetDelay = 2000) {
|
||||
button.innerHTML = `<i class="bi bi-check-circle me-1"></i>${successText}`;
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
resetButton(button);
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-primary');
|
||||
}, resetDelay);
|
||||
}
|
||||
|
||||
// Make functions available globally
|
||||
window.showErrorToast = showErrorToast;
|
||||
window.showSuccessToast = showSuccessToast;
|
||||
window.handleAuthenticationError = handleAuthenticationError;
|
||||
window.handleApiError = handleApiError;
|
||||
window.setButtonLoading = setButtonLoading;
|
||||
window.resetButton = resetButton;
|
||||
window.setButtonSuccess = setButtonSuccess;
|
51
src/static/js/wallet.js
Normal file
51
src/static/js/wallet.js
Normal file
@@ -0,0 +1,51 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
function hydrate(id){try{const el=document.getElementById(id);return el?JSON.parse(el.textContent||'{}'):null;}catch(e){console.warn('wallet hydration parse failed',e);return null;}}
|
||||
const h=hydrate('wallet-hydration')||{}; const SYMBOL=h.currency_symbol||'$';
|
||||
|
||||
function initTotalCost(){const a=document.getElementById('creditsAmount');const t=document.getElementById('totalCost');if(!a||!t) return; const upd=()=>{const v=parseFloat(a.value)||0; t.textContent=(v*0.1).toFixed(2);}; a.addEventListener('input',upd); upd();}
|
||||
|
||||
async function buyCredits(){const amount=document.getElementById('creditsAmount')?.value;const pm=document.getElementById('buyPaymentMethod')?.value; if(!amount||!pm){alert('Please fill in all required fields');return;} try{const data=await apiJson('/api/wallet/buy-credits',{method:'POST',body:{amount:parseFloat(amount),payment_method:pm}}); alert('Credits purchase successful!'); location.reload();}catch(e){alert('Error processing purchase: '+(e?.message||'Unknown error'));}}
|
||||
|
||||
async function transferCredits(){const toUser=document.getElementById('recipientEmail')?.value;const amount=document.getElementById('transferAmount')?.value;const note=document.getElementById('transferNote')?.value; if(!toUser||!amount){alert('Please fill in all required fields');return;} try{await apiJson('/api/wallet/transfer-credits',{method:'POST',body:{to_user:toUser,amount:parseFloat(amount),note:note||null}}); alert('Transfer successful!'); location.reload();}catch(e){alert('Error processing transfer: '+(e?.message||'Unknown error'));}}
|
||||
|
||||
async function refreshWalletData(){try{const d=await apiJson('/api/wallet/info',{cache:'no-store'}); if(d&&d.balance!==undefined){const bal=Number(d.balance); const usdEq=(bal*0.1).toFixed(2); const balEl=document.getElementById('wallet-balance'); const usdEl=document.getElementById('usd-equivalent'); const availEl=document.getElementById('availableBalance'); if(balEl) balEl.textContent=SYMBOL+bal; if(usdEl) usdEl.textContent=SYMBOL+usdEq; if(availEl) availEl.textContent=bal; if(window.loadNavbarData) window.loadNavbarData();}}catch(e){console.error('Error refreshing wallet data:',e);}}
|
||||
|
||||
function updateQuickButtonsAmounts(){const symEl=document.getElementById('currencySymbol'); const quick=document.getElementById('quickAmountButtons'); const note=document.getElementById('conversionNote'); if(symEl) symEl.textContent=SYMBOL; if(quick){quick.querySelectorAll('button[data-amount]').forEach(btn=>{const amt=btn.getAttribute('data-amount'); btn.textContent=`${SYMBOL}${amt}`;});} if(note) note.textContent='Direct USD credits purchase';}
|
||||
|
||||
async function saveAutoTopupSettings(){const en=!!document.getElementById('autoTopupEnabled')?.checked; const thr=parseFloat(document.getElementById('thresholdAmount')?.value||'0'); const top=parseFloat(document.getElementById('topupAmount')?.value||'0'); const pm=document.getElementById('autoTopupPaymentMethod')?.value; const daily=document.getElementById('dailyLimit')?.value?parseFloat(document.getElementById('dailyLimit').value):null; try{await apiJson('/api/wallet/auto-topup/configure',{method:'POST',body:{enabled:en,threshold_amount:thr,topup_amount:top,payment_method_id:pm,daily_limit:daily,monthly_limit:null}}); alert('Auto top-up settings saved successfully!');}catch(e){console.error('Auto top-up settings error:',e);alert('Failed to save settings. Please try again.');}}
|
||||
|
||||
async function loadAutoTopupSettings(){try{const j=await apiJson('/api/wallet/auto-topup/status',{cache:'no-store'}); if(j&&j.settings){const s=j.settings; const el=id=>document.getElementById(id); if(el('autoTopupEnabled')) el('autoTopupEnabled').checked=s.enabled; if(el('thresholdAmount')) el('thresholdAmount').value=s.threshold_amount; if(el('topupAmount')) el('topupAmount').value=s.topup_amount; if(el('autoTopupPaymentMethod')) el('autoTopupPaymentMethod').value=s.payment_method_id; if((s.daily_limit!==undefined&&s.daily_limit!==null)&&el('dailyLimit')) el('dailyLimit').value=s.daily_limit; }}catch(e){console.error('Failed to load auto top-up settings:',e);}}
|
||||
|
||||
async function quickTopUp(amount){const v=parseFloat(amount); if(!v||v<=0){alert('Please enter a valid amount');return;} try{const data=await apiJson('/api/wallet/quick-topup',{method:'POST',body:{amount:v,payment_method:'credit_card'}}); const usdAmt=(data&&data.usd_amount)!=null?data.usd_amount:v; alert(`Successfully added ${SYMBOL}${usdAmt} to your wallet!`); location.reload();}catch(e){console.error('Quick top-up error:',e);alert('Failed to process top-up. Please try again.');}}
|
||||
|
||||
function initOnLoad(){const usd=document.getElementById('usd-equivalent'); if(usd){const m=(usd.textContent||'').match(/\$?(\d+\.?\d*)/); if(m){usd.textContent=SYMBOL+Number(m[1]).toFixed(2);}} updateQuickButtonsAmounts(); loadAutoTopupSettings(); const params=new URLSearchParams(window.location.search); if(params.get('action')==='topup'){const sec=document.querySelector('.card.border-success'); if(sec){sec.scrollIntoView({behavior:'smooth'}); sec.classList.add('border-warning'); setTimeout(()=>sec.classList.remove('border-warning'),3000);} const amt=params.get('amount'); if(amt){const ce=document.getElementById('customAmount'); if(ce) ce.value=amt;}}}
|
||||
|
||||
// Expose globals for onclick usage in template
|
||||
window.buyCredits=buyCredits;
|
||||
window.transferCredits=transferCredits;
|
||||
window.quickTopUp=quickTopUp;
|
||||
window.updateTopupAmounts=updateQuickButtonsAmounts;
|
||||
window.refreshWalletData=refreshWalletData;
|
||||
window.saveAutoTopupSettings=saveAutoTopupSettings;
|
||||
window.loadAutoTopupSettings=loadAutoTopupSettings;
|
||||
|
||||
function bindWalletEventListeners(){
|
||||
const quickWrap=document.getElementById('quickAmountButtons');
|
||||
if(quickWrap){ quickWrap.querySelectorAll('button[data-amount]').forEach(btn=>{ btn.addEventListener('click',()=>{ const amt=btn.getAttribute('data-amount'); quickTopUp(amt); }); }); }
|
||||
const customBtn=document.getElementById('customAmountAddBtn');
|
||||
if(customBtn){ customBtn.addEventListener('click',()=>{ const val=document.getElementById('customAmount')?.value; quickTopUp(val); }); }
|
||||
const currencySel=document.getElementById('topupCurrency');
|
||||
if(currencySel){ currencySel.addEventListener('change', updateQuickButtonsAmounts); }
|
||||
const refreshBtn=document.getElementById('refreshWalletBtn');
|
||||
if(refreshBtn){ refreshBtn.addEventListener('click', refreshWalletData); }
|
||||
const saveBtn=document.getElementById('saveAutoTopupBtn');
|
||||
if(saveBtn){ saveBtn.addEventListener('click', saveAutoTopupSettings); }
|
||||
const buyBtn=document.getElementById('buyCreditsBtn');
|
||||
if(buyBtn){ buyBtn.addEventListener('click', buyCredits); }
|
||||
const transferBtn=document.getElementById('transferCreditsBtn');
|
||||
if(transferBtn){ transferBtn.addEventListener('click', transferCredits); }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded',()=>{initTotalCost(); initOnLoad(); bindWalletEventListeners();});
|
||||
})();
|
Reference in New Issue
Block a user