2026-04-15-4
This commit is contained in:
+110
-22
@@ -13,50 +13,138 @@ const analytics = {
|
||||
{ name: 'Sun', requests: 3490, latency: 225 },
|
||||
],
|
||||
|
||||
init: () => {
|
||||
console.log('📈 Initializing Analytics...');
|
||||
analytics.renderVolumeChart();
|
||||
analytics.renderLatencyChart();
|
||||
init: async () => {
|
||||
console.log('📈 Initializing Live Analytics...');
|
||||
const headers = auth.getAuthHeader();
|
||||
|
||||
// 1. Fetch Summary for Success Ratio
|
||||
try {
|
||||
const summaryRes = await fetch('/api/usage/summary', { headers });
|
||||
if (summaryRes.ok) {
|
||||
const summary = await summaryRes.json();
|
||||
analytics.renderSuccessRatio(summary.successRate);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
|
||||
// 2. Fetch History for Volume Chart
|
||||
const history = await analytics.fetchHistory();
|
||||
if (history && history.length > 0) {
|
||||
analytics.renderVolumeChart(history);
|
||||
} else {
|
||||
analytics.renderPlaceholder();
|
||||
}
|
||||
|
||||
analytics.renderLatencyChart();
|
||||
},
|
||||
|
||||
renderVolumeChart: () => {
|
||||
renderSuccessRatio: (percent) => {
|
||||
const circle = document.getElementById('success-circle');
|
||||
const display = document.getElementById('success-percent-display');
|
||||
if (!circle || !display) return;
|
||||
|
||||
display.textContent = `${percent}%`;
|
||||
|
||||
// Circumference is 552.9 (2 * PI * 88)
|
||||
const offset = 552.9 - (percent / 100) * 552.9;
|
||||
circle.style.strokeDashoffset = offset;
|
||||
circle.classList.toggle('text-emerald-500', percent >= 95);
|
||||
circle.classList.toggle('text-blue-600', percent < 95);
|
||||
},
|
||||
|
||||
fetchHistory: async () => {
|
||||
try {
|
||||
const headers = auth.getAuthHeader();
|
||||
const res = await fetch('/api/usage/history?days=7', { headers });
|
||||
if (res.ok) {
|
||||
const raw = await res.json();
|
||||
// Map raw database records to chart data
|
||||
return raw.map(item => {
|
||||
const date = new Date(item.date);
|
||||
return {
|
||||
name: date.toLocaleDateString('en-US', { weekday: 'short' }),
|
||||
requests: parseInt(item.count, 10)
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analytics history', error);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
renderVolumeChart: (data) => {
|
||||
const container = document.getElementById('v-chart');
|
||||
if (!container) return;
|
||||
|
||||
const max = Math.max(...analytics.mockData.map(d => d.requests));
|
||||
const max = Math.max(...data.map(d => d.requests), 1);
|
||||
|
||||
container.innerHTML = analytics.mockData.map(d => `
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<div class="w-full bg-blue-600/20 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
|
||||
<div class="w-full bg-gradient-to-t from-blue-600 to-blue-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
|
||||
style="height: ${(d.requests / max) * 100}%">
|
||||
container.innerHTML = data.map((d, i) => `
|
||||
<div class="flex-1 flex flex-col items-center gap-4 group h-full">
|
||||
<div class="flex-1 w-full bg-slate-900/40 rounded-2xl relative overflow-hidden flex items-end p-1 border border-white/[0.03] backdrop-blur-sm">
|
||||
<div class="w-full bg-gradient-to-t from-blue-600 via-blue-500 to-cyan-400 rounded-xl transition-all duration-1000 ease-out hover:brightness-125 shadow-[0_0_30px_rgba(37,99,235,0.2)]"
|
||||
style="height: 0%; transition-delay: ${i * 50}ms">
|
||||
<script>
|
||||
setTimeout(() => {
|
||||
document.querySelectorAll('.group h-full div[style*="height: 0%"]')[0].style.height = "${(d.requests / max) * 100}%";
|
||||
}, 100);
|
||||
</script>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.requests}</span>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0 z-10">
|
||||
<span class="text-[10px] font-black bg-blue-600 text-white px-3 py-1.5 rounded-lg shadow-2xl border border-blue-400/30 mb-2">${d.requests.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">${d.name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Trigger animations after render
|
||||
setTimeout(() => {
|
||||
container.querySelectorAll('.w-full.bg-gradient-to-t').forEach((el, index) => {
|
||||
const height = el.parentElement.parentElement.dataset.height;
|
||||
el.style.height = el.getAttribute('data-target-height');
|
||||
});
|
||||
}, 100);
|
||||
},
|
||||
|
||||
renderPlaceholder: () => {
|
||||
const container = document.getElementById('v-chart');
|
||||
if (container) container.innerHTML = `
|
||||
<div class="w-full h-full flex flex-col items-center justify-center text-slate-500 gap-4">
|
||||
<div class="w-16 h-16 rounded-full bg-slate-900 flex items-center justify-center opacity-50">
|
||||
<i data-lucide="bar-chart" class="w-8 h-8"></i>
|
||||
</div>
|
||||
<p class="text-xs italic font-bold tracking-widest uppercase opacity-40">No activity recorded for this period</p>
|
||||
</div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
},
|
||||
|
||||
renderLatencyChart: () => {
|
||||
const container = document.getElementById('l-chart');
|
||||
if (!container) return;
|
||||
|
||||
const max = Math.max(...analytics.mockData.map(d => d.latency));
|
||||
const mockLatency = [
|
||||
{ name: 'Mon', latency: 240 },
|
||||
{ name: 'Tue', latency: 198 },
|
||||
{ name: 'Wed', latency: 310 },
|
||||
{ name: 'Thu', latency: 208 },
|
||||
{ name: 'Fri', latency: 250 },
|
||||
{ name: 'Sat', latency: 210 },
|
||||
{ name: 'Sun', latency: 225 },
|
||||
];
|
||||
|
||||
const max = Math.max(...mockLatency.map(d => d.latency));
|
||||
|
||||
container.innerHTML = analytics.mockData.map(d => `
|
||||
<div class="flex-1 flex flex-col items-center gap-2 group">
|
||||
<div class="w-full bg-violet-600/10 rounded-t-lg relative overflow-hidden flex items-end" style="height: 100%">
|
||||
<div class="w-full bg-gradient-to-t from-violet-600 to-violet-400 rounded-t-lg transition-all duration-700 hover:brightness-125"
|
||||
container.innerHTML = mockLatency.map((d, i) => `
|
||||
<div class="flex-1 flex flex-col items-center gap-4 group h-full">
|
||||
<div class="flex-1 w-full bg-slate-900/40 rounded-2xl relative overflow-hidden flex items-end p-1 border border-white/[0.03] backdrop-blur-sm">
|
||||
<div class="w-full bg-gradient-to-t from-violet-600 via-violet-500 to-fuchsia-400 rounded-xl transition-all duration-1000 ease-out hover:brightness-125 shadow-[0_0_30px_rgba(139,92,246,0.2)]"
|
||||
style="height: ${(d.latency / max) * 100}%">
|
||||
</div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span class="text-[10px] font-bold bg-slate-900 px-2 py-1 rounded border border-slate-800">${d.latency}ms</span>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform translate-y-2 group-hover:translate-y-0 z-10">
|
||||
<span class="text-[10px] font-black bg-violet-600 text-white px-3 py-1.5 rounded-lg shadow-2xl border border-violet-400/30 mb-2">${d.latency}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[10px] font-bold text-slate-500">${d.name}</span>
|
||||
<span class="text-[10px] font-black text-slate-500 uppercase tracking-widest">${d.name}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
+87
-23
@@ -11,13 +11,18 @@ const app = {
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
console.log('🚀 Dashboard Initializing...');
|
||||
console.log('🚀 Dashboard Initializing Components...');
|
||||
app.bindEvents();
|
||||
app.handleRouting();
|
||||
await app.fetchData();
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
onAuthenticated: async () => {
|
||||
console.log('🔑 User Authenticated, Fetching Data...');
|
||||
await app.fetchData();
|
||||
app.updateStats();
|
||||
},
|
||||
|
||||
bindEvents: () => {
|
||||
window.addEventListener('hashchange', app.handleRouting);
|
||||
|
||||
@@ -50,25 +55,31 @@ const app = {
|
||||
playground.init(app.state.keys);
|
||||
} else if (hash === 'analytics') {
|
||||
analytics.init();
|
||||
} else if (hash === 'billing') {
|
||||
billing.init();
|
||||
} else if (hash === 'refinement') {
|
||||
refinement.init();
|
||||
} else if (hash === 'docs') {
|
||||
docs.init();
|
||||
}
|
||||
},
|
||||
|
||||
fetchData: async () => {
|
||||
const headers = auth.getAuthHeader();
|
||||
try {
|
||||
// Fetch Tenant
|
||||
const tenantRes = await fetch('/api/auth/management/me');
|
||||
const tenantRes = await fetch('/api/auth/management/me', { headers });
|
||||
if (tenantRes.ok) {
|
||||
app.state.tenant = await tenantRes.data || await tenantRes.json();
|
||||
app.state.tenant = await tenantRes.json();
|
||||
app.updateHeader();
|
||||
}
|
||||
|
||||
// Fetch Keys
|
||||
if (app.state.tenant && app.state.tenant.id) {
|
||||
const keysRes = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`);
|
||||
if (app.state.tenant) {
|
||||
const keysRes = await fetch('/api/auth/management/keys', { headers });
|
||||
if (keysRes.ok) {
|
||||
app.state.keys = await keysRes.json();
|
||||
app.renderKeysTable();
|
||||
app.updateStats();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -80,21 +91,73 @@ const app = {
|
||||
if (!app.state.tenant) return;
|
||||
document.getElementById('tenant-name').textContent = app.state.tenant.name;
|
||||
document.getElementById('tenant-email').textContent = app.state.tenant.email;
|
||||
document.getElementById('welcome-msg').textContent = `Welcome back, ${app.state.tenant.name}`;
|
||||
document.getElementById('welcome-msg').textContent = `Welcome back, ${app.state.tenant.name.split(' ')[0]}`;
|
||||
|
||||
// Role-based UI visibility
|
||||
if (app.state.tenant.role === 'ADMIN') {
|
||||
const auditLink = document.getElementById('nav-refinement');
|
||||
if (auditLink) auditLink.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Update user avatar if photoUrl exists
|
||||
if (app.state.tenant.photoUrl) {
|
||||
const avatarContainer = document.querySelector('.w-10.h-10.rounded-full.bg-slate-900');
|
||||
if (avatarContainer) {
|
||||
avatarContainer.innerHTML = `<img src="${app.state.tenant.photoUrl}" class="w-full h-full rounded-full object-cover border border-blue-500/20" alt="Profile">`;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
updateStats: () => {
|
||||
document.getElementById('active-keys-count').textContent = app.state.keys.length;
|
||||
updateStats: async () => {
|
||||
try {
|
||||
const headers = auth.getAuthHeader();
|
||||
const res = await fetch('/api/usage/summary', { headers });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
|
||||
// Update KPI Cards
|
||||
document.getElementById('active-keys-count').textContent = app.state.keys.length;
|
||||
|
||||
// Detailed Stats from getUsageSummary
|
||||
if (document.getElementById('stat-total-req')) {
|
||||
document.getElementById('stat-total-req').textContent = data.monthlyUsage.toLocaleString();
|
||||
}
|
||||
if (document.getElementById('stat-success-percent')) {
|
||||
document.getElementById('stat-success-percent').textContent = `${data.successRate}%`;
|
||||
document.getElementById('stat-success-rate').textContent = data.successRate >= 95 ? 'Excellent' : 'Stable';
|
||||
}
|
||||
if (document.getElementById('stat-avg-latency')) {
|
||||
document.getElementById('stat-avg-latency').textContent = `${data.avgLatency}ms`;
|
||||
document.getElementById('stat-latency-val').textContent = `-${Math.round(data.avgLatency * 0.1)}ms`;
|
||||
}
|
||||
|
||||
// Update Progress bar & Quota card
|
||||
const bar = document.getElementById('usage-progress-bar');
|
||||
const label = document.getElementById('usage-percentage-label');
|
||||
const limitText = document.getElementById('usage-limit-text');
|
||||
|
||||
if (bar) bar.style.width = `${data.percentage}%`;
|
||||
if (label) label.textContent = `${data.percentage}% USED`;
|
||||
if (limitText) {
|
||||
const remaining = Math.max(0, data.limit - data.monthlyUsage);
|
||||
limitText.textContent = `${remaining.toLocaleString()} requests left`;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update stats', error);
|
||||
}
|
||||
|
||||
// Inject random bars for traffic
|
||||
// Inject sample bars for visual flair if chart not ready
|
||||
const container = document.getElementById('traffic-bars');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
const values = [40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90];
|
||||
values.forEach(h => {
|
||||
// High-density bars for a tech/premium feel
|
||||
const values = [40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 40, 60, 55, 80, 70, 45, 90, 85, 60, 40, 30, 55, 75, 50, 65, 80, 70, 30];
|
||||
values.forEach((h, index) => {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'flex-1 bg-gradient-to-t from-blue-600/20 to-blue-400/80 rounded-t-sm relative group hover:to-blue-300 transition-all';
|
||||
bar.className = 'flex-1 bg-gradient-to-t from-blue-600/10 to-blue-400/60 rounded-t-[2px] relative group hover:to-blue-300 transition-all duration-500';
|
||||
bar.style.height = `${h}%`;
|
||||
bar.style.transitionDelay = `${index * 20}ms`;
|
||||
bar.innerHTML = `<div class="absolute -top-10 left-1/2 -translate-x-1/2 glass px-2 py-1 rounded text-[10px] font-bold opacity-0 group-hover:opacity-100 transition-opacity z-10">${h}k</div>`;
|
||||
container.appendChild(bar);
|
||||
});
|
||||
@@ -112,12 +175,10 @@ const app = {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable create button if limit reached (per React logic)
|
||||
if (app.state.keys.length >= 1) {
|
||||
createBtn.disabled = true;
|
||||
createBtn.title = "Limit of 1 API key per developer reached";
|
||||
createBtn.innerHTML = '<i data-lucide="shield-alert" class="w-4 h-4"></i> Limit Reached';
|
||||
}
|
||||
// Enable create button (removed previous limit restrictiveness)
|
||||
createBtn.disabled = false;
|
||||
createBtn.title = "Create a new API key";
|
||||
createBtn.innerHTML = '<i data-lucide="plus" class="w-4 h-4"></i> Create Key';
|
||||
|
||||
tbody.innerHTML = app.state.keys.map(key => {
|
||||
const isVisible = app.state.showKeys[key.id];
|
||||
@@ -195,9 +256,12 @@ const app = {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Creating...';
|
||||
|
||||
const res = await fetch(`/api/auth/management/keys/${app.state.tenant.id}`, {
|
||||
const res = await fetch('/api/auth/management/keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...auth.getAuthHeader()
|
||||
},
|
||||
body: JSON.stringify({ name, rateLimit: 100 })
|
||||
});
|
||||
|
||||
@@ -217,5 +281,5 @@ const app = {
|
||||
}
|
||||
};
|
||||
|
||||
// Start app
|
||||
// Start app components
|
||||
document.addEventListener('DOMContentLoaded', app.init);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Firebase Authentication Logic for Intaleq Dashboard
|
||||
*/
|
||||
|
||||
const auth = {
|
||||
firebaseAuth: null,
|
||||
currentUser: null,
|
||||
idToken: null,
|
||||
|
||||
init: () => {
|
||||
console.log('🔐 Initializing Auth Module...');
|
||||
|
||||
// 1. Initialize Firebase
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
auth.firebaseAuth = firebase.auth();
|
||||
|
||||
// 2. Listen for Auth Changes
|
||||
auth.firebaseAuth.onAuthStateChanged(async (user) => {
|
||||
if (user) {
|
||||
console.log('✅ User logged in:', user.email);
|
||||
auth.currentUser = user;
|
||||
auth.idToken = await user.getIdToken();
|
||||
|
||||
// Show Dashboard, Hide Login
|
||||
auth.toggleUI(true);
|
||||
|
||||
// Initialize main app data
|
||||
app.onAuthenticated();
|
||||
} else {
|
||||
console.log('❌ No active session.');
|
||||
auth.currentUser = null;
|
||||
auth.idToken = null;
|
||||
|
||||
// Show Login, Hide Dashboard
|
||||
auth.toggleUI(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
signInWithGoogle: async () => {
|
||||
const provider = new firebase.auth.GoogleAuthProvider();
|
||||
try {
|
||||
await auth.firebaseAuth.signInWithPopup(provider);
|
||||
} catch (error) {
|
||||
console.error('Sign-in error:', error);
|
||||
alert('Failed to sign in. Please try again.');
|
||||
}
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
try {
|
||||
await auth.firebaseAuth.signOut();
|
||||
} catch (error) {
|
||||
console.error('Sign-out error:', error);
|
||||
}
|
||||
},
|
||||
|
||||
toggleUI: (isAuthenticated) => {
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const mainSidebar = document.getElementById('main-sidebar');
|
||||
const mainContent = document.getElementById('main-content');
|
||||
|
||||
if (isAuthenticated) {
|
||||
if (loginSection) loginSection.classList.add('hidden');
|
||||
if (mainSidebar) mainSidebar.classList.remove('hidden');
|
||||
if (mainContent) mainContent.classList.remove('hidden');
|
||||
} else {
|
||||
if (loginSection) loginSection.classList.remove('hidden');
|
||||
if (mainSidebar) mainSidebar.classList.add('hidden');
|
||||
if (mainContent) mainContent.classList.add('hidden');
|
||||
}
|
||||
},
|
||||
|
||||
getAuthHeader: () => {
|
||||
return auth.idToken ? { 'Authorization': `Bearer ${auth.idToken}` } : {};
|
||||
}
|
||||
};
|
||||
|
||||
// Start Auth on Load
|
||||
document.addEventListener('DOMContentLoaded', auth.init);
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Billing & Subscription Logic
|
||||
*/
|
||||
|
||||
const billing = {
|
||||
state: {
|
||||
subscription: null,
|
||||
invoices: []
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
console.log('💳 Initializing Billing...');
|
||||
await billing.fetchSubscription();
|
||||
await billing.fetchInvoices();
|
||||
billing.renderUI();
|
||||
},
|
||||
|
||||
fetchSubscription: async () => {
|
||||
try {
|
||||
const headers = auth.getAuthHeader();
|
||||
const res = await fetch('/api/billing/subscription', { headers });
|
||||
if (res.ok) {
|
||||
billing.state.subscription = await res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch subscription', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchInvoices: async () => {
|
||||
try {
|
||||
const headers = auth.getAuthHeader();
|
||||
const res = await fetch('/api/billing/invoices', { headers });
|
||||
if (res.ok) {
|
||||
billing.state.invoices = await res.json();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch invoices', error);
|
||||
}
|
||||
},
|
||||
|
||||
renderUI: () => {
|
||||
const sub = billing.state.subscription;
|
||||
if (!sub) return;
|
||||
|
||||
// 1. Update Current Plan Badges
|
||||
document.querySelectorAll('.current-plan-name').forEach(el => el.textContent = sub.plan);
|
||||
|
||||
// 2. Render Plan Cards logic
|
||||
const plans = ['FREE', 'PRO', 'ENTERPRISE'];
|
||||
plans.forEach(p => {
|
||||
const card = document.getElementById(`plan-card-${p.toLowerCase()}`);
|
||||
if (card) {
|
||||
const btn = card.querySelector('.plan-btn');
|
||||
if (p === sub.plan) {
|
||||
card.classList.add('border-blue-500/50');
|
||||
if (btn) {
|
||||
btn.textContent = 'Current Plan';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('opacity-50');
|
||||
}
|
||||
} else {
|
||||
card.classList.remove('border-blue-500/50');
|
||||
if (btn) {
|
||||
btn.textContent = p === 'ENTERPRISE' ? 'Contact Sales' : 'Upgrade Now';
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Render Invoice History
|
||||
const tbody = document.getElementById('invoice-table-body');
|
||||
if (tbody) {
|
||||
if (billing.state.invoices.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="py-10 text-center text-slate-500 text-xs">No transactions yet.</td></tr>`;
|
||||
} else {
|
||||
tbody.innerHTML = billing.state.invoices.map(inv => `
|
||||
<tr class="border-b border-white/[0.02] hover:bg-white/[0.01]">
|
||||
<td class="py-4 text-xs font-medium">${new Date(inv.createdAt).toLocaleDateString()}</td>
|
||||
<td class="py-4 text-xs font-bold">$${inv.amount}</td>
|
||||
<td class="py-4 text-xs">
|
||||
<span class="px-2 py-0.5 rounded bg-slate-800 text-[10px] uppercase font-black">${inv.provider}</span>
|
||||
</td>
|
||||
<td class="py-4 text-xs">
|
||||
<span class="status-badge ${inv.status.toLowerCase()}">${inv.status}</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
startCheckout: async (plan, provider) => {
|
||||
try {
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Processing...';
|
||||
btn.disabled = true;
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...auth.getAuthHeader()
|
||||
};
|
||||
|
||||
const res = await fetch('/api/billing/checkout', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ plan, provider })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
}
|
||||
} else {
|
||||
alert('Checkout failed. Please try again.');
|
||||
}
|
||||
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
} catch (error) {
|
||||
console.error('Checkout error', error);
|
||||
alert('An error occurred during checkout.');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Documentation Engine for Intaleq Dashboard
|
||||
*/
|
||||
|
||||
const docs = {
|
||||
init: () => {
|
||||
console.log('📚 Initializing Documentation...');
|
||||
docs.bindEvents();
|
||||
docs.renderSection('getting-started');
|
||||
},
|
||||
|
||||
bindEvents: () => {
|
||||
// Handle side-nav clicks
|
||||
document.querySelectorAll('.docs-nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const section = e.currentTarget.getAttribute('data-section');
|
||||
docs.renderSection(section);
|
||||
|
||||
// Active state
|
||||
document.querySelectorAll('.docs-nav-link').forEach(l => l.classList.remove('active', 'bg-blue-500/10', 'text-blue-400'));
|
||||
e.currentTarget.classList.add('active', 'bg-blue-500/10', 'text-blue-400');
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
renderSection: (id) => {
|
||||
const container = document.getElementById('docs-content');
|
||||
if (!container) return;
|
||||
|
||||
// Content repository
|
||||
const content = {
|
||||
'getting-started': `
|
||||
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div>
|
||||
<h3 class="text-3xl font-black mb-4">Getting Started</h3>
|
||||
<p class="text-slate-400 leading-relaxed">Welcome to the Intaleq Map Platform. Our APIs allow you to integrate high-quality vector maps, geocoding, and routing into your web and mobile applications with ease.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="glass p-6 rounded-2xl border-white/5">
|
||||
<h4 class="font-bold mb-2 flex items-center gap-2">
|
||||
<i data-lucide="key" class="w-4 h-4 text-blue-400"></i>
|
||||
1. Get an API Key
|
||||
</h4>
|
||||
<p class="text-xs text-slate-500">Go to the Credentials page and create your first API key.</p>
|
||||
</div>
|
||||
<div class="glass p-6 rounded-2xl border-white/5">
|
||||
<h4 class="font-bold mb-2 flex items-center gap-2">
|
||||
<i data-lucide="code" class="w-4 h-4 text-emerald-400"></i>
|
||||
2. Install SDK
|
||||
</h4>
|
||||
<p class="text-xs text-slate-500">Use our MapLibre wrappers for JavaScript or Flutter.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-bold">Base URL</h4>
|
||||
<div class="bg-slate-900 rounded-xl p-4 font-mono text-sm border border-slate-800 text-blue-400">
|
||||
https://map-dashbord.intaleqapp.com/api
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
'tiles-api': `
|
||||
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div>
|
||||
<h3 class="text-3xl font-black mb-2">Vector Tiles API</h3>
|
||||
<p class="text-slate-400">Render high-performance vector maps from our global database.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="endpoint-card glass rounded-2xl border-white/5 overflow-hidden">
|
||||
<div class="p-4 bg-white/[0.02] border-b border-white/5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-[10px] font-black rounded uppercase">GET</span>
|
||||
<code class="text-xs font-bold">/maps/style.json</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<p class="text-sm text-slate-400 mb-6">Returns the MapLibre-compatible style configuration. Use the <code>theme</code> parameter to switch between 'light' and 'obsidian'.</p>
|
||||
|
||||
<h5 class="text-xs font-black uppercase tracking-widest text-slate-500 mb-4">Code Example</h5>
|
||||
<div class="relative group">
|
||||
<pre class="bg-slate-950 p-4 rounded-xl text-xs font-mono text-slate-300 leading-relaxed overflow-x-auto">
|
||||
// Initialize MapLibre with Intaleq Style
|
||||
const map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: 'https://map-dashbord.intaleqapp.com/api/maps/style.json?theme=obsidian',
|
||||
center: [35.9106, 31.9539], // Amman
|
||||
zoom: 12
|
||||
});</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
'geocoding-api': `
|
||||
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div>
|
||||
<h3 class="text-3xl font-black mb-2">Geocoding API</h3>
|
||||
<p class="text-slate-400">Convert addresses to coordinates (Forward) or coordinates to addresses (Reverse).</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint-card glass rounded-2xl border-white/5 overflow-hidden">
|
||||
<div class="p-4 bg-white/[0.02] border-b border-white/5 flex items-center gap-3">
|
||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-[10px] font-black rounded uppercase">GET</span>
|
||||
<code class="text-xs font-bold">/geocoding/search</code>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<table class="w-full text-left text-xs mb-6">
|
||||
<thead>
|
||||
<tr class="text-slate-500 uppercase tracking-widest font-black border-b border-white/5">
|
||||
<th class="pb-3">Parameter</th>
|
||||
<th class="pb-3">Type</th>
|
||||
<th class="pb-3">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-white/[0.02]">
|
||||
<tr>
|
||||
<td class="py-3 font-bold text-blue-400 font-mono">q</td>
|
||||
<td class="py-3 text-slate-500">string</td>
|
||||
<td class="py-3">Search query (address, place, coordinates)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="py-3 font-bold text-blue-400 font-mono">limit</td>
|
||||
<td class="py-3 text-slate-500">number</td>
|
||||
<td class="py-3">Max results (default: 5)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h5 class="text-xs font-black uppercase tracking-widest text-slate-500 mb-4">Request</h5>
|
||||
<pre class="bg-slate-950 p-4 rounded-xl text-xs font-mono text-blue-400 mb-4">curl "https://map-dashbord.intaleqapp.com/api/geocoding/search?q=Amman&limit=1" \\
|
||||
-H "x-api-key: YOUR_API_KEY"</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
};
|
||||
|
||||
container.innerHTML = content[id] || '<p class="text-slate-500">Documentation section coming soon...</p>';
|
||||
lucide.createIcons();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAwybkM9FFkF2KRM0bFNAaEPNPbMZBWFn8",
|
||||
authDomain: "intaleq-map.firebaseapp.com",
|
||||
projectId: "intaleq-map",
|
||||
storageBucket: "intaleq-map.firebasestorage.app",
|
||||
messagingSenderId: "1052695318500",
|
||||
appId: "1:1052695318500:web:7913981536524a630401f1",
|
||||
measurementId: "G-P5Z2YXH481"
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* i18n Translation Engine
|
||||
*/
|
||||
|
||||
const i18n = {
|
||||
currentLang: localStorage.getItem('intaleq_lang') || 'en',
|
||||
|
||||
translations: {
|
||||
en: {
|
||||
// Navbar
|
||||
'nav-features': 'Features',
|
||||
'nav-why': 'Why Us?',
|
||||
'nav-pricing': 'Pricing',
|
||||
'nav-launch': 'Launch Dashboard',
|
||||
'lang-toggle': 'العربية',
|
||||
|
||||
// Hero
|
||||
'hero-badge': 'Now with 3D Buildings in Jordan & Syria',
|
||||
'hero-title': 'The Map API That <br> <span class="text-blue-500">Doesn\'t Break</span> The Bank.',
|
||||
'hero-desc': 'Build premium location-based apps with high-fidelity vector tiles, optimized routing, and 3D buildings. 85% cheaper than Google Maps.',
|
||||
'hero-cta-start': 'Start Building Free',
|
||||
'hero-cta-view': 'View Comparison',
|
||||
|
||||
// Features
|
||||
'feat-latency-title': 'Zero Latency',
|
||||
'feat-latency-desc': 'Our infrastructure is optimized for MENA region, ensuring map tiles load in under 200ms.',
|
||||
'feat-routing-title': 'Smart Routing',
|
||||
'feat-routing-desc': 'Enterprise-grade routing engine with support for alternative paths and traffic awareness.',
|
||||
'feat-geocoding-title': 'Local Geocoding',
|
||||
'feat-geocoding-desc': 'Highly accurate search for local landmarks and neighborhoods in Jordan & Syria.',
|
||||
|
||||
// Dashboard Sidebar
|
||||
'side-dashboard': 'Dashboard',
|
||||
'side-playground': 'Playground',
|
||||
'side-analytics': 'Analytics',
|
||||
'side-billing': 'Billing',
|
||||
'side-audit': 'Place Audit',
|
||||
'side-docs': 'Documentation',
|
||||
'side-upgrade': 'Upgrade Plan',
|
||||
|
||||
// Dashboard General
|
||||
'welcome': 'Welcome back',
|
||||
'system-status': 'System Operational',
|
||||
'quota-card-title': 'Monthly Quota',
|
||||
'requests-left': 'requests left',
|
||||
'used-label': 'USED',
|
||||
|
||||
// KPI Labels
|
||||
'kpi-total-req': 'Total Requests',
|
||||
'kpi-success-rate': 'Success Rate',
|
||||
'kpi-active-keys': 'Active Keys',
|
||||
'kpi-latency': 'Avg Latency',
|
||||
},
|
||||
ar: {
|
||||
// Navbar
|
||||
'nav-features': 'المميزات',
|
||||
'nav-why': 'لماذا نحن؟',
|
||||
'nav-pricing': 'الأسعار',
|
||||
'nav-launch': 'لوحة التحكم',
|
||||
'lang-toggle': 'English',
|
||||
|
||||
// Hero
|
||||
'hero-badge': 'الآن مع المباني ثلاثية الأبعاد في الأردن وسوريا',
|
||||
'hero-title': 'واجهة خرائط برمجية <br> <span class="text-blue-500">لا ترهق</span> ميزانيتك.',
|
||||
'hero-desc': 'ابنِ تطبيقات خرائط فاخرة مع خرائط مجهزة، توجيه ذكي، ومباني ثلاثية الأبعاد. أوفر بنسبة 85% من خرائط جوجل.',
|
||||
'hero-cta-start': 'ابدأ مجاناً',
|
||||
'hero-cta-view': 'قارن الأسعار',
|
||||
|
||||
// Features
|
||||
'feat-latency-title': 'سرعة فائقة',
|
||||
'feat-latency-desc': 'بنيتنا التحتية محسنة لمنطقة الشرق الأوسط، مما يضمن تحميل الخرائط في أقل من 200 مللي ثانية.',
|
||||
'feat-routing-title': 'توجيه ذكي',
|
||||
'feat-routing-desc': 'محرك توجيه من الفئة المؤسسية يدعم المسارات البديلة والوعي بحركة المرور.',
|
||||
'feat-geocoding-title': 'بحث مكاني محلي',
|
||||
'feat-geocoding-desc': 'دقة عالية جداً في البحث عن المعالم والأحياء في الأردن وسوريا.',
|
||||
|
||||
// Dashboard Sidebar
|
||||
'side-dashboard': 'لوحة التحكم',
|
||||
'side-playground': 'ساحة الاختبار',
|
||||
'side-analytics': 'التحليلات',
|
||||
'side-billing': 'الفواتير',
|
||||
'side-audit': 'تدقيق الأماكن',
|
||||
'side-docs': 'التوثيق',
|
||||
'side-upgrade': 'ترقية الخطة',
|
||||
|
||||
// Dashboard General
|
||||
'welcome': 'مرحباً بك مجدداً',
|
||||
'system-status': 'النظام يعمل بكفاءة',
|
||||
'quota-card-title': 'رصيد الاستهلاك',
|
||||
'requests-left': 'طلب متبقي',
|
||||
'used-label': 'مستهلك',
|
||||
|
||||
// KPI Labels
|
||||
'kpi-total-req': 'إجمالي الطلبات',
|
||||
'kpi-success-rate': 'نسبة النجاح',
|
||||
'kpi-active-keys': 'المفاتيح النشطة',
|
||||
'kpi-latency': 'متوسط سرعة الاستجابة',
|
||||
}
|
||||
},
|
||||
|
||||
init: () => {
|
||||
i18n.apply(i18n.currentLang);
|
||||
},
|
||||
|
||||
toggle: () => {
|
||||
const nextLang = i18n.currentLang === 'en' ? 'ar' : 'en';
|
||||
i18n.currentLang = nextLang;
|
||||
localStorage.setItem('intaleq_lang', nextLang);
|
||||
i18n.apply(nextLang);
|
||||
},
|
||||
|
||||
apply: (lang) => {
|
||||
const rtl = lang === 'ar';
|
||||
document.documentElement.dir = rtl ? 'rtl' : 'ltr';
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
// Apply font classes
|
||||
if (rtl) {
|
||||
document.body.style.fontFamily = "'Cairo', sans-serif";
|
||||
document.body.classList.add('rtl-mode');
|
||||
} else {
|
||||
document.body.style.fontFamily = "'Plus Jakarta Sans', sans-serif";
|
||||
document.body.classList.remove('rtl-mode');
|
||||
}
|
||||
|
||||
// Mirror Sidebar if in dashboard
|
||||
const sidebar = document.getElementById('main-sidebar');
|
||||
if (sidebar) {
|
||||
if (rtl) {
|
||||
sidebar.classList.add('order-last', 'border-l', 'border-r-0');
|
||||
sidebar.classList.remove('border-r');
|
||||
} else {
|
||||
sidebar.classList.remove('order-last', 'border-l');
|
||||
sidebar.classList.add('border-r');
|
||||
}
|
||||
}
|
||||
|
||||
// Translate elements with data-i18n attribute
|
||||
document.querySelectorAll('[data-i18n]').forEach(el => {
|
||||
const key = el.getAttribute('data-i18n');
|
||||
const translation = i18n.translations[lang][key];
|
||||
if (translation) {
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
|
||||
el.placeholder = translation;
|
||||
} else {
|
||||
el.innerHTML = translation;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger icon re-render if lucide is present
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on load
|
||||
document.addEventListener('DOMContentLoaded', i18n.init);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+12
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+83
File diff suppressed because one or more lines are too long
@@ -14,6 +14,13 @@ const playground = {
|
||||
playground.renderKeySelect(keys);
|
||||
playground.bindEvents();
|
||||
|
||||
// Watch for section changes to trigger resize
|
||||
window.addEventListener('hashchange', () => {
|
||||
if (window.location.hash === '#playground' && playground.map) {
|
||||
setTimeout(() => playground.map.resize(), 100);
|
||||
}
|
||||
});
|
||||
|
||||
if (keys.length > 0) {
|
||||
playground.selectedKey = keys[0].key;
|
||||
playground.loadMap();
|
||||
@@ -53,30 +60,55 @@ const playground = {
|
||||
try {
|
||||
// Set RTL Text Plugin
|
||||
if (maplibregl.getRTLTextPluginStatus() === 'unavailable') {
|
||||
console.log('🌐 Loading MapLibre RTL Plugin...');
|
||||
maplibregl.setRTLTextPlugin(
|
||||
'https://unpkg.com/@mapbox/mapbox-gl-rtl-text@0.2.3/mapbox-gl-rtl-text.js',
|
||||
'js/plugins/mapbox-gl-rtl-text.js',
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🛰️ Fetching Map Style for theme: ${playground.currentStyle}...`);
|
||||
const styleUrl = `/api/maps/style.json?api_key=${playground.selectedKey}&theme=${playground.currentStyle}`;
|
||||
const res = await fetch(styleUrl);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Style fetch failed with status: ${res.status}`);
|
||||
}
|
||||
|
||||
const styleData = await res.json();
|
||||
console.log('🎨 Style fetched successfully, initializing MapLibre...');
|
||||
|
||||
playground.map = new maplibregl.Map({
|
||||
container: 'map',
|
||||
style: styleData,
|
||||
center: [35.91, 31.95], // Amman, Jordan
|
||||
zoom: 12,
|
||||
attributionControl: false
|
||||
attributionControl: false,
|
||||
trackResize: true
|
||||
});
|
||||
|
||||
playground.map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
playground.map.on('load', () => {
|
||||
console.log('Map loaded!');
|
||||
playground.map.resize();
|
||||
console.log('✅ Map engine ready and tiles loading!');
|
||||
// Wait slightly for container animation to finish
|
||||
setTimeout(() => {
|
||||
playground.map.resize();
|
||||
const container = document.getElementById('map');
|
||||
if (container && container.offsetWidth > 0) {
|
||||
console.log('📏 Map container size verified:', container.offsetWidth, 'x', container.offsetHeight);
|
||||
} else {
|
||||
console.warn('⚠️ Map container has 0 width. Potential visibility issue.');
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
playground.map.on('error', (e) => {
|
||||
console.error('❌ MapLibre Error Detail:', e.error || e);
|
||||
if (e.error && e.error.message.includes('Style')) {
|
||||
alert('Map Style Error: Please verify your API Key and Network connection.');
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Map Refinement (Place Audit) logic
|
||||
*/
|
||||
|
||||
const refinement = {
|
||||
state: {
|
||||
candidates: []
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
console.log('📍 Initializing Map Refinement Logic...');
|
||||
await refinement.fetchCandidates();
|
||||
},
|
||||
|
||||
fetchCandidates: async () => {
|
||||
const headers = auth.getAuthHeader();
|
||||
try {
|
||||
const res = await fetch('/api/map-refinement/candidates?status=PENDING', { headers });
|
||||
if (res.ok) {
|
||||
refinement.state.candidates = await res.json();
|
||||
refinement.renderTable();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch candidates', error);
|
||||
}
|
||||
},
|
||||
|
||||
renderTable: () => {
|
||||
const tbody = document.getElementById('refinement-table-body');
|
||||
if (!tbody) return;
|
||||
|
||||
if (refinement.state.candidates.length === 0) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="5" class="py-20 text-center text-slate-500">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<i data-lucide="map-pin" class="w-8 h-8 opacity-20"></i>
|
||||
<p>No pending location suggestions.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = refinement.state.candidates.map(c => `
|
||||
<tr class="hover:bg-white/[0.02] transition-colors border-b border-white/[0.03]">
|
||||
<td class="px-8 py-6">
|
||||
<div class="font-bold text-white">${c.name_ar || c.name}</div>
|
||||
<div class="text-[10px] text-slate-500 uppercase font-bold mt-1">${c.country}</div>
|
||||
</td>
|
||||
<td class="px-8 py-6">
|
||||
<span class="px-2 py-1 rounded-lg bg-blue-500/10 text-blue-400 text-[10px] font-black uppercase tracking-wider">${c.category || 'General'}</span>
|
||||
</td>
|
||||
<td class="px-8 py-6 font-mono text-xs text-slate-400">
|
||||
${parseFloat(c.latitude).toFixed(5)}, ${parseFloat(c.longitude).toFixed(5)}
|
||||
</td>
|
||||
<td class="px-8 py-6 text-sm text-slate-400 font-medium">
|
||||
${c.submittedBy || 'System User'}
|
||||
</td>
|
||||
<td class="px-8 py-6 text-right space-x-2">
|
||||
<button onclick="refinement.reject('${c.id}')" class="p-2.5 rounded-xl bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-all" title="Reject">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
<button onclick="refinement.approve('${c.id}')" class="p-2.5 rounded-xl bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 transition-all border border-emerald-500/20 shadow-lg shadow-emerald-500/10" title="Approve">
|
||||
<i data-lucide="check" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
lucide.createIcons();
|
||||
},
|
||||
|
||||
approve: async (id) => {
|
||||
if (!confirm('Are you sure you want to approve this location and add it to the production map?')) return;
|
||||
|
||||
const headers = auth.getAuthHeader();
|
||||
try {
|
||||
const res = await fetch(`/api/map-refinement/candidates/${id}/approve`, {
|
||||
method: 'PATCH',
|
||||
headers
|
||||
});
|
||||
if (res.ok) {
|
||||
await refinement.fetchCandidates();
|
||||
} else {
|
||||
alert('Approval failed.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
|
||||
reject: async (id) => {
|
||||
const reason = prompt('Please enter a reason for rejection:');
|
||||
if (reason === null) return;
|
||||
|
||||
const headers = auth.getAuthHeader();
|
||||
try {
|
||||
const res = await fetch(`/api/map-refinement/candidates/${id}/reject`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ reason })
|
||||
});
|
||||
if (res.ok) {
|
||||
await refinement.fetchCandidates();
|
||||
} else {
|
||||
alert('Rejection failed.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user