init projectmycelium

This commit is contained in:
mik-tf
2025-09-01 21:37:01 -04:00
commit b41efb0e99
319 changed files with 128160 additions and 0 deletions

402
src/static/css/styles.css Normal file
View 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
View 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>

View 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">&amp; 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 909 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

View 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
View 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
View 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();

View 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
View 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
View 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);
});
}
});
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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();
}
});

File diff suppressed because it is too large Load Diff

View 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();
});
}

View 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();
}
})();

File diff suppressed because it is too large Load Diff

189
src/static/js/dashboard.js Normal file
View 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();
});
})();

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

View 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();
});
})();

View 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();
}
})();

View 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();
});
})();

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

View 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;
}

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

View 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);
});
});
});
});
})();

View 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;
}

View 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);
}
})();

View 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();
});
});
})();

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

View 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);

View 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
View 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,
};
})();

View 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,
};
})();

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

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

View 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
View 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();
}
})();

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

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

View 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;
}

View 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;
})();

View 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
View 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();});
})();