Compare commits
10 commits
315bcae4ca
...
8d442b7c6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d442b7c6f | |||
| 8c3faeec3f | |||
| 35c407f9aa | |||
| d2866276a9 | |||
| f32e1bb9ae | |||
| 340dc22c20 | |||
| 16db140bb6 | |||
| ae836dad54 | |||
| 03179647ec | |||
| 306549a656 |
5 changed files with 333 additions and 207 deletions
10
crud.py
10
crud.py
|
|
@ -103,7 +103,7 @@ async def get_client_dashboard_summary(user_id: str) -> Optional[ClientDashboard
|
|||
# Calculate metrics
|
||||
total_invested = confirmed_deposits # Total invested = all confirmed deposits
|
||||
remaining_balance = confirmed_deposits - dca_spent # Remaining = deposits - DCA spending
|
||||
avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / fiat spent
|
||||
avg_cost_basis = total_sats / dca_spent if dca_spent > 0 else 0 # Cost basis = sats / GTQ
|
||||
|
||||
# Calculate current fiat value of total sats
|
||||
current_sats_fiat_value = 0.0
|
||||
|
|
@ -248,7 +248,7 @@ async def get_client_analytics(user_id: str, time_range: str = "30d") -> Optiona
|
|||
# Build cost basis history
|
||||
cost_basis_history = []
|
||||
for record in cost_basis_data:
|
||||
avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0
|
||||
avg_cost_basis = record["cumulative_sats"] / record["cumulative_fiat"] if record["cumulative_fiat"] > 0 else 0 # Cost basis = sats / GTQ
|
||||
# Use transaction_date (which is COALESCE(transaction_time, created_at))
|
||||
date_to_use = record["transaction_date"]
|
||||
if date_to_use is None:
|
||||
|
|
@ -514,4 +514,8 @@ async def get_client_by_user_id(user_id: str) -> Optional[dict]:
|
|||
)
|
||||
return dict(client) if client else None
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# Removed get_active_lamassu_config - client should not access sensitive admin config
|
||||
# Client limits are now fetched via secure public API endpoint
|
||||
50
models.py
50
models.py
|
|
@ -6,16 +6,46 @@ from typing import List, Optional
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# Client Dashboard Data Models
|
||||
class ClientDashboardSummary(BaseModel):
|
||||
"""Summary metrics for client dashboard overview"""
|
||||
# API Models for Client Dashboard (Frontend communication in GTQ)
|
||||
class ClientDashboardSummaryAPI(BaseModel):
|
||||
"""API model - client dashboard summary in GTQ"""
|
||||
user_id: str
|
||||
total_sats_accumulated: int
|
||||
total_fiat_invested: int # Confirmed deposits
|
||||
pending_fiat_deposits: int # Pending deposits awaiting confirmation
|
||||
current_sats_fiat_value: float # Current fiat value of total sats
|
||||
average_cost_basis: float # Average sats per fiat unit
|
||||
current_fiat_balance: int # Available balance for DCA
|
||||
total_fiat_invested_gtq: float # Confirmed deposits in GTQ
|
||||
pending_fiat_deposits_gtq: float # Pending deposits in GTQ
|
||||
current_sats_fiat_value_gtq: float # Current fiat value of total sats in GTQ
|
||||
average_cost_basis: float # Average sats per GTQ
|
||||
current_fiat_balance_gtq: float # Available balance for DCA in GTQ
|
||||
total_transactions: int
|
||||
dca_mode: str # 'flow' or 'fixed'
|
||||
dca_status: str # 'active' or 'inactive'
|
||||
last_transaction_date: Optional[datetime]
|
||||
currency: str = "GTQ"
|
||||
|
||||
|
||||
class ClientTransactionAPI(BaseModel):
|
||||
"""API model - client transaction in GTQ"""
|
||||
id: str
|
||||
amount_sats: int
|
||||
amount_fiat_gtq: float # Amount in GTQ
|
||||
exchange_rate: float
|
||||
transaction_type: str # 'flow', 'fixed', 'manual'
|
||||
status: str
|
||||
created_at: datetime
|
||||
transaction_time: Optional[datetime] = None # Original ATM transaction time
|
||||
lamassu_transaction_id: Optional[str] = None
|
||||
|
||||
|
||||
# Internal Models for Client Dashboard (Database storage in GTQ)
|
||||
class ClientDashboardSummary(BaseModel):
|
||||
"""Internal model - client dashboard summary stored in GTQ"""
|
||||
user_id: str
|
||||
total_sats_accumulated: int
|
||||
total_fiat_invested: float # Confirmed deposits in GTQ
|
||||
pending_fiat_deposits: float # Pending deposits awaiting confirmation in GTQ
|
||||
current_sats_fiat_value: float # Current fiat value of total sats in GTQ
|
||||
average_cost_basis: float # Average sats per GTQ
|
||||
current_fiat_balance: float # Available balance for DCA in GTQ
|
||||
total_transactions: int
|
||||
dca_mode: str # 'flow' or 'fixed'
|
||||
dca_status: str # 'active' or 'inactive'
|
||||
|
|
@ -24,10 +54,10 @@ class ClientDashboardSummary(BaseModel):
|
|||
|
||||
|
||||
class ClientTransaction(BaseModel):
|
||||
"""Read-only view of client's DCA transactions"""
|
||||
"""Internal model - client transaction stored in GTQ"""
|
||||
id: str
|
||||
amount_sats: int
|
||||
amount_fiat: int
|
||||
amount_fiat: float # Amount in GTQ (e.g., 150.75)
|
||||
exchange_rate: float
|
||||
transaction_type: str # 'flow', 'fixed', 'manual'
|
||||
status: str
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
window.app = Vue.createApp({
|
||||
el: '#dcaClient',
|
||||
el: '#vue',
|
||||
mixins: [windowMixin],
|
||||
delimiters: ['${', '}'],
|
||||
data: function () {
|
||||
|
|
@ -7,14 +7,19 @@ window.app = Vue.createApp({
|
|||
// Registration state
|
||||
isRegistered: false,
|
||||
registrationChecked: false,
|
||||
showRegistrationDialog: false,
|
||||
registrationForm: {
|
||||
selectedWallet: null,
|
||||
dca_mode: 'flow',
|
||||
fixed_mode_daily_limit: null,
|
||||
username: ''
|
||||
},
|
||||
|
||||
|
||||
// Admin configuration
|
||||
adminConfig: {
|
||||
max_daily_limit_gtq: 2000,
|
||||
currency: 'GTQ'
|
||||
},
|
||||
|
||||
// Dashboard state
|
||||
dashboardData: null,
|
||||
transactions: [],
|
||||
|
|
@ -72,6 +77,23 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
methods: {
|
||||
// Configuration Methods
|
||||
async loadClientLimits() {
|
||||
try {
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
'/satmachineadmin/api/v1/dca/client-limits'
|
||||
// No authentication required - public endpoint with safe data only
|
||||
)
|
||||
|
||||
this.adminConfig = data
|
||||
console.log('Client limits loaded:', this.adminConfig)
|
||||
} catch (error) {
|
||||
console.error('Error loading client limits:', error)
|
||||
// Keep default values if client limits fail to load
|
||||
}
|
||||
},
|
||||
|
||||
// Registration Methods
|
||||
async checkRegistrationStatus() {
|
||||
try {
|
||||
|
|
@ -80,17 +102,16 @@ window.app = Vue.createApp({
|
|||
'/satmachineclient/api/v1/registration-status',
|
||||
this.g.user.wallets[0].adminkey
|
||||
)
|
||||
|
||||
|
||||
this.isRegistered = data.is_registered
|
||||
this.registrationChecked = true
|
||||
|
||||
|
||||
if (!this.isRegistered) {
|
||||
this.showRegistrationDialog = true
|
||||
// Pre-fill username and default wallet if available
|
||||
this.registrationForm.username = this.g.user.username || ''
|
||||
this.registrationForm.selectedWallet = this.g.user.wallets[0] || null
|
||||
// Fetch current user info to get the username
|
||||
await this.loadCurrentUser()
|
||||
this.registrationForm.selectedWallet = this.g.user.wallets[0]?.id || null
|
||||
}
|
||||
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Error checking registration status:', error)
|
||||
|
|
@ -99,35 +120,56 @@ window.app = Vue.createApp({
|
|||
}
|
||||
},
|
||||
|
||||
async loadCurrentUser() {
|
||||
try {
|
||||
const { data } = await LNbits.api.getAuthenticatedUser()
|
||||
|
||||
// Set username from API response with priority: display_name > username > email > fallback
|
||||
const username = data.extra?.display_name || data.username || data.email
|
||||
this.registrationForm.username = (username !== null && username !== undefined && username !== '')
|
||||
? username
|
||||
: `user_${this.g.user.id.substring(0, 8)}`
|
||||
} catch (error) {
|
||||
console.error('Error loading current user:', error)
|
||||
// Fallback to generated username
|
||||
this.registrationForm.username = `user_${this.g.user.id.substring(0, 8)}`
|
||||
}
|
||||
},
|
||||
|
||||
async registerClient() {
|
||||
try {
|
||||
// Prepare registration data similar to the admin test client creation
|
||||
// Prepare registration data using the form's username (already loaded from API)
|
||||
const registrationData = {
|
||||
dca_mode: this.registrationForm.dca_mode,
|
||||
fixed_mode_daily_limit: this.registrationForm.fixed_mode_daily_limit,
|
||||
username: this.registrationForm.username || this.g.user.username || `user_${this.g.user.id.substring(0, 8)}`
|
||||
username: this.registrationForm.username || `user_${this.g.user.id.substring(0, 8)}`
|
||||
}
|
||||
|
||||
// Find the selected wallet object to get the adminkey
|
||||
const selectedWallet = this.g.user.wallets.find(w => w.id === this.registrationForm.selectedWallet)
|
||||
if (!selectedWallet) {
|
||||
throw new Error('Selected wallet not found')
|
||||
}
|
||||
|
||||
const { data } = await LNbits.api.request(
|
||||
'POST',
|
||||
'/satmachineclient/api/v1/register',
|
||||
this.registrationForm.selectedWallet.adminkey,
|
||||
selectedWallet.adminkey,
|
||||
registrationData
|
||||
)
|
||||
|
||||
|
||||
this.isRegistered = true
|
||||
this.showRegistrationDialog = false
|
||||
|
||||
|
||||
this.$q.notify({
|
||||
type: 'positive',
|
||||
message: data.message || 'Successfully registered for DCA!',
|
||||
icon: 'check_circle',
|
||||
position: 'top'
|
||||
})
|
||||
|
||||
|
||||
// Load dashboard data after successful registration
|
||||
await this.loadDashboardData()
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error registering client:', error)
|
||||
this.$q.notify({
|
||||
|
|
@ -141,24 +183,26 @@ window.app = Vue.createApp({
|
|||
// Dashboard Methods
|
||||
formatCurrency(amount) {
|
||||
if (!amount) return 'Q 0.00';
|
||||
// Values are already in full currency units, not centavos
|
||||
// Amount is already in GTQ
|
||||
const gtqAmount = amount;
|
||||
return new Intl.NumberFormat('es-GT', {
|
||||
style: 'currency',
|
||||
currency: 'GTQ',
|
||||
}).format(amount);
|
||||
}).format(gtqAmount);
|
||||
},
|
||||
|
||||
formatCurrencyWithCode(amount, currencyCode) {
|
||||
if (!amount) return `${currencyCode} 0.00`;
|
||||
// Format with the provided currency code
|
||||
// Amount is already in GTQ
|
||||
const currencyAmount = amount;
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(amount);
|
||||
}).format(currencyAmount);
|
||||
} catch (error) {
|
||||
// Fallback if currency code is not supported
|
||||
return `${currencyCode} ${amount.toFixed(2)}`;
|
||||
return `${currencyCode} ${currencyAmount.toFixed(2)}`;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -188,10 +232,16 @@ window.app = Vue.createApp({
|
|||
formatSats(amount) {
|
||||
if (!amount) return '0 sats'
|
||||
const formatted = new Intl.NumberFormat('en-US').format(amount)
|
||||
// Add some excitement for larger amounts
|
||||
if (amount >= 1000000) return formatted + ' sats 💎'
|
||||
if (amount >= 100000) return formatted + ' sats 🚀'
|
||||
if (amount >= 10000) return formatted + ' sats ⚡'
|
||||
// Add some excitement for larger amounts with consistent 5x→2x progression
|
||||
if (amount >= 100000000) return formatted + ' sats 🏆' // Full coiner (1 BTC)
|
||||
if (amount >= 50000000) return formatted + ' sats 🎆' // Bitcoin baron
|
||||
if (amount >= 10000000) return formatted + ' sats 👑' // Bitcoin royalty
|
||||
if (amount >= 5000000) return formatted + ' sats 🏆' // Verified bag holder
|
||||
if (amount >= 1000000) return formatted + ' sats 🌟' // Millionaire
|
||||
if (amount >= 500000) return formatted + ' sats 🔥' // Half million
|
||||
if (amount >= 100000) return formatted + ' sats 🚀' // Getting serious
|
||||
if (amount >= 50000) return formatted + ' sats ⚡' // Lightning quick
|
||||
if (amount >= 10000) return formatted + ' sats 🎯' // First milestone
|
||||
return formatted + ' sats'
|
||||
},
|
||||
|
||||
|
|
@ -266,15 +316,20 @@ window.app = Vue.createApp({
|
|||
},
|
||||
|
||||
getNextMilestone() {
|
||||
if (!this.dashboardData) return { target: 100000, name: '100k sats' }
|
||||
if (!this.dashboardData) return { target: 10000, name: '10k sats' }
|
||||
const sats = this.dashboardData.total_sats_accumulated
|
||||
|
||||
// Consistent 5x→2x progression pattern
|
||||
if (sats < 10000) return { target: 10000, name: '10k sats' }
|
||||
if (sats < 50000) return { target: 50000, name: '50k sats' }
|
||||
if (sats < 100000) return { target: 100000, name: '100k sats' }
|
||||
if (sats < 500000) return { target: 500000, name: '500k sats' }
|
||||
if (sats < 1000000) return { target: 1000000, name: '1M sats' }
|
||||
if (sats < 2100000) return { target: 2100000, name: '2.1M sats' }
|
||||
return { target: 21000000, name: '21M sats' }
|
||||
if (sats < 5000000) return { target: 5000000, name: '5M sats' }
|
||||
if (sats < 10000000) return { target: 10000000, name: '10M sats' }
|
||||
if (sats < 50000000) return { target: 50000000, name: '50M sats' }
|
||||
if (sats < 100000000) return { target: 100000000, name: '100M sats (1 BTC!)' }
|
||||
return { target: 500000000, name: '500M sats (5 BTC)' }
|
||||
},
|
||||
|
||||
getMilestoneProgress() {
|
||||
|
|
@ -297,18 +352,18 @@ window.app = Vue.createApp({
|
|||
console.log('Chart already loading, ignoring request')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
this.chartLoading = true
|
||||
|
||||
|
||||
// Destroy existing chart immediately to prevent conflicts
|
||||
if (this.dcaChart) {
|
||||
console.log('Destroying existing chart before loading new data')
|
||||
this.dcaChart.destroy()
|
||||
this.dcaChart = null
|
||||
}
|
||||
|
||||
const {data} = await LNbits.api.request(
|
||||
|
||||
const { data } = await LNbits.api.request(
|
||||
'GET',
|
||||
`/satmachineclient/api/v1/dashboard/analytics?time_range=${this.chartTimeRange}`,
|
||||
this.g.user.wallets[0].adminkey
|
||||
|
|
@ -321,10 +376,10 @@ window.app = Vue.createApp({
|
|||
}
|
||||
|
||||
this.analyticsData = data
|
||||
|
||||
|
||||
// Wait for DOM update and ensure we're still in loading state
|
||||
await this.$nextTick()
|
||||
|
||||
|
||||
// Double-check we're still the active loading request
|
||||
if (this.chartLoading) {
|
||||
this.initDCAChart()
|
||||
|
|
@ -343,13 +398,13 @@ window.app = Vue.createApp({
|
|||
console.log('analyticsData:', this.analyticsData)
|
||||
console.log('dcaChart ref:', this.$refs.dcaChart)
|
||||
console.log('chartLoading state:', this.chartLoading)
|
||||
|
||||
|
||||
// Skip if we're not in a loading state (indicates this is a stale call)
|
||||
if (!this.chartLoading && this.dcaChart) {
|
||||
console.log('Chart already exists and not loading, skipping initialization')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (!this.analyticsData) {
|
||||
console.log('No analytics data available')
|
||||
return
|
||||
|
|
@ -371,7 +426,7 @@ window.app = Vue.createApp({
|
|||
console.error('Chart.js is not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
console.log('Chart.js version:', Chart.version || 'unknown')
|
||||
console.log('Chart.js available:', typeof Chart)
|
||||
|
||||
|
|
@ -383,45 +438,45 @@ window.app = Vue.createApp({
|
|||
}
|
||||
|
||||
const ctx = this.$refs.dcaChart.getContext('2d')
|
||||
|
||||
|
||||
// Use accumulation_timeline data which is already grouped by day
|
||||
const timelineData = this.analyticsData.accumulation_timeline || []
|
||||
console.log('Timeline data sample:', timelineData.slice(0, 2)) // Debug first 2 records
|
||||
|
||||
|
||||
// If we have timeline data, use it (already grouped by day)
|
||||
if (timelineData.length > 0) {
|
||||
// Calculate running totals from daily data
|
||||
let runningSats = 0
|
||||
const labels = []
|
||||
const cumulativeSats = []
|
||||
|
||||
|
||||
timelineData.forEach(point => {
|
||||
// Ensure sats is a valid number
|
||||
const sats = point.sats || 0
|
||||
const validSats = typeof sats === 'number' ? sats : parseFloat(sats) || 0
|
||||
runningSats += validSats
|
||||
|
||||
|
||||
const date = new Date(point.date)
|
||||
if (!isNaN(date.getTime())) {
|
||||
labels.push(date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
labels.push(date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
}))
|
||||
cumulativeSats.push(runningSats)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
console.log('Timeline chart data:', { labels, cumulativeSats })
|
||||
|
||||
|
||||
this.createChart(labels, cumulativeSats)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Fallback to cost_basis_history but group by date to avoid duplicates
|
||||
console.log('No timeline data, using cost_basis_history as fallback')
|
||||
const chartData = this.analyticsData.cost_basis_history || []
|
||||
console.log('Chart data sample:', chartData.slice(0, 2)) // Debug first 2 records
|
||||
|
||||
|
||||
// Handle empty data case
|
||||
if (chartData.length === 0) {
|
||||
console.log('No chart data available')
|
||||
|
|
@ -429,7 +484,7 @@ window.app = Vue.createApp({
|
|||
const placeholderGradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
placeholderGradient.addColorStop(0, 'rgba(255, 149, 0, 0.3)')
|
||||
placeholderGradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
|
||||
|
||||
|
||||
// Show placeholder chart with enhanced styling
|
||||
this.dcaChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
|
|
@ -463,7 +518,7 @@ window.app = Vue.createApp({
|
|||
borderWidth: 2,
|
||||
cornerRadius: 8,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
label: function (context) {
|
||||
return `${context.parsed.y.toLocaleString()} sats`
|
||||
}
|
||||
}
|
||||
|
|
@ -486,7 +541,7 @@ window.app = Vue.createApp({
|
|||
ticks: {
|
||||
color: '#666666',
|
||||
font: { size: 12, weight: '500' },
|
||||
callback: function(value) {
|
||||
callback: function (value) {
|
||||
return value.toLocaleString() + ' sats'
|
||||
}
|
||||
}
|
||||
|
|
@ -498,7 +553,7 @@ window.app = Vue.createApp({
|
|||
this.chartLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Group cost_basis_history by date to eliminate duplicates
|
||||
const groupedData = new Map()
|
||||
chartData.forEach(point => {
|
||||
|
|
@ -513,30 +568,30 @@ window.app = Vue.createApp({
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
const uniqueChartData = Array.from(groupedData.values()).sort((a, b) =>
|
||||
|
||||
const uniqueChartData = Array.from(groupedData.values()).sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
)
|
||||
|
||||
|
||||
const labels = uniqueChartData.map(point => {
|
||||
// Handle different date formats with enhanced timezone handling
|
||||
let date;
|
||||
if (point.date) {
|
||||
console.log('Raw date from API:', point.date); // Debug the actual date string
|
||||
|
||||
|
||||
// If it's an ISO string with timezone info, parse it correctly
|
||||
if (typeof point.date === 'string' && point.date.includes('T')) {
|
||||
// ISO string - parse and convert to local date
|
||||
date = new Date(point.date);
|
||||
// For display purposes, use the date part only to avoid timezone shifts
|
||||
const localDateStr = date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
const localDateStr = date.getFullYear() + '-' +
|
||||
String(date.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(date.getDate()).padStart(2, '0');
|
||||
date = new Date(localDateStr + 'T00:00:00'); // Force local midnight
|
||||
} else {
|
||||
date = new Date(point.date);
|
||||
}
|
||||
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(date.getTime())) {
|
||||
date = new Date();
|
||||
|
|
@ -544,9 +599,9 @@ window.app = Vue.createApp({
|
|||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
|
||||
console.log('Formatted date:', date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
|
||||
|
||||
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
|
|
@ -557,22 +612,22 @@ window.app = Vue.createApp({
|
|||
const sats = point.cumulative_sats || 0
|
||||
return typeof sats === 'number' ? sats : parseFloat(sats) || 0
|
||||
})
|
||||
|
||||
|
||||
console.log('Final chart data:', { labels, cumulativeSats })
|
||||
console.log('Labels array:', labels)
|
||||
console.log('CumulativeSats array:', cumulativeSats)
|
||||
|
||||
|
||||
// Validate data before creating chart
|
||||
if (labels.length === 0 || cumulativeSats.length === 0) {
|
||||
console.warn('No valid data for chart, skipping creation')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (labels.length !== cumulativeSats.length) {
|
||||
console.warn('Mismatched data arrays:', { labelsLength: labels.length, dataLength: cumulativeSats.length })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Check for any invalid values in cumulativeSats
|
||||
const hasInvalidValues = cumulativeSats.some(val => val === null || val === undefined || isNaN(val))
|
||||
if (hasInvalidValues) {
|
||||
|
|
@ -585,34 +640,34 @@ window.app = Vue.createApp({
|
|||
|
||||
createChart(labels, cumulativeSats) {
|
||||
console.log('createChart called with loading state:', this.chartLoading)
|
||||
|
||||
|
||||
if (!this.$refs.dcaChart) {
|
||||
console.log('Chart ref not available for createChart')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Skip if we're not in a loading state (indicates this is a stale call)
|
||||
if (!this.chartLoading) {
|
||||
console.log('Not in loading state, skipping createChart')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Destroy existing chart
|
||||
if (this.dcaChart) {
|
||||
console.log('Destroying existing chart in createChart')
|
||||
this.dcaChart.destroy()
|
||||
this.dcaChart = null
|
||||
}
|
||||
|
||||
|
||||
const ctx = this.$refs.dcaChart.getContext('2d')
|
||||
|
||||
|
||||
try {
|
||||
// Create gradient for the area fill
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
|
||||
gradient.addColorStop(0, 'rgba(255, 149, 0, 0.4)')
|
||||
gradient.addColorStop(0.5, 'rgba(255, 149, 0, 0.2)')
|
||||
gradient.addColorStop(1, 'rgba(255, 149, 0, 0.05)')
|
||||
|
||||
|
||||
// Small delay to ensure Chart.js is fully initialized
|
||||
setTimeout(() => {
|
||||
try {
|
||||
|
|
@ -621,7 +676,7 @@ window.app = Vue.createApp({
|
|||
console.log('Loading state changed during timeout, aborting chart creation')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
this.dcaChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
|
|
@ -662,7 +717,7 @@ window.app = Vue.createApp({
|
|||
cornerRadius: 8,
|
||||
displayColors: false,
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
title: function (context) {
|
||||
return `📅 ${context[0].label}`
|
||||
},
|
||||
label: function (context) {
|
||||
|
|
@ -741,10 +796,13 @@ window.app = Vue.createApp({
|
|||
async created() {
|
||||
try {
|
||||
this.loading = true
|
||||
|
||||
// Check registration status first
|
||||
|
||||
// Load client limits first
|
||||
await this.loadClientLimits()
|
||||
|
||||
// Check registration status
|
||||
await this.checkRegistrationStatus()
|
||||
|
||||
|
||||
// Only load dashboard data if registered
|
||||
if (this.isRegistered) {
|
||||
await Promise.all([
|
||||
|
|
@ -768,7 +826,7 @@ window.app = Vue.createApp({
|
|||
console.log('Loading state:', this.loading)
|
||||
console.log('Chart ref available:', !!this.$refs.dcaChart)
|
||||
console.log('Analytics data available:', !!this.analyticsData)
|
||||
|
||||
|
||||
if (this.analyticsData && this.$refs.dcaChart) {
|
||||
console.log('Initializing chart from mounted hook')
|
||||
this.initDCAChart()
|
||||
|
|
@ -781,6 +839,14 @@ window.app = Vue.createApp({
|
|||
computed: {
|
||||
hasData() {
|
||||
return this.dashboardData && !this.loading && this.isRegistered
|
||||
},
|
||||
|
||||
walletOptions() {
|
||||
if (!this.g.user?.wallets) return []
|
||||
return this.g.user.wallets.map(wallet => ({
|
||||
label: `${wallet.name} (${Math.round(wallet.balance_msat / 1000)} sats)`,
|
||||
value: wallet.id
|
||||
}))
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
|
||||
<script src="{{ static_url_for('satmachineclient/static', path='js/index.js') }}"></script>
|
||||
{% endblock %} {% block page %}
|
||||
<div class="row q-col-gutter-md" id="dcaClient">
|
||||
<div class="row q-col-gutter-md" id="vue">
|
||||
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
|
||||
|
||||
<!-- Loading State -->
|
||||
|
|
@ -21,19 +21,87 @@
|
|||
<!-- Error State -->
|
||||
<q-card v-if="error && !loading" class="bg-negative text-white">
|
||||
<q-card-section>
|
||||
<q-icon name="error" class="q-mr-sm" />
|
||||
<q-icon name="error" class="q-mr-sm"></q-icon>
|
||||
${error}
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- Not Registered State -->
|
||||
<q-card v-if="registrationChecked && !isRegistered && !loading" class="bg-orange-1">
|
||||
<q-card-section class="text-center">
|
||||
<q-icon name="account_circle" size="3em" color="orange" />
|
||||
<div class="text-h6 q-mt-md text-orange-8">Welcome to Bitcoin DCA!</div>
|
||||
<div class="text-body2 text-grey-7 q-mt-sm">
|
||||
Please complete your registration to start your Dollar Cost Averaging journey.
|
||||
<!-- Registration Form Card -->
|
||||
<q-card v-if="registrationChecked && !isRegistered && !loading" class="q-mb-md">
|
||||
<q-card-section>
|
||||
<div class="text-center q-mb-lg">
|
||||
<div>
|
||||
<q-icon name="account_circle" size="4em" color="orange"></q-icon>
|
||||
</div>
|
||||
<div class="text-h5 q-mt-md text-orange-8">Welcome to DCA!</div>
|
||||
<div class="text-body2 text-grey-7">Let's set up your Bitcoin Dollar Cost Averaging account</div>
|
||||
</div>
|
||||
|
||||
<q-form @submit="registerClient" class="q-gutter-md">
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="registrationForm.selectedWallet"
|
||||
:options="walletOptions"
|
||||
label="DCA Wallet *"
|
||||
hint="Choose which wallet will receive your Bitcoin DCA purchases"
|
||||
></q-select>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
emit-value
|
||||
v-model="registrationForm.dca_mode"
|
||||
:options="[
|
||||
{ label: 'Flow Mode (Recommended)', value: 'flow' },
|
||||
{ label: 'Fixed Mode', value: 'fixed' }
|
||||
]"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
label="DCA Strategy *"
|
||||
hint="Choose how your Bitcoin purchases will be distributed"
|
||||
></q-select>
|
||||
|
||||
<q-input
|
||||
v-if="registrationForm.dca_mode === 'fixed'"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.number="registrationForm.fixed_mode_daily_limit"
|
||||
label="Daily Limit (GTQ)"
|
||||
placeholder="Enter daily purchase limit"
|
||||
:hint="`Maximum amount to purchase per day (Admin limit: ${adminConfig.max_daily_limit_gtq} GTQ)`"
|
||||
:rules="[
|
||||
val => registrationForm.dca_mode !== 'fixed' || (val && val > 0) || 'Daily limit is required for fixed mode',
|
||||
val => registrationForm.dca_mode !== 'fixed' || val <= adminConfig.max_daily_limit_gtq || `Daily limit cannot exceed ${adminConfig.max_daily_limit_gtq} GTQ (admin maximum)`
|
||||
]"
|
||||
></q-input>
|
||||
|
||||
<q-banner class="bg-blue-1 text-blue-9 q-mt-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="info" color="blue"></q-icon>
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
<strong>Flow Mode:</strong> Your Bitcoin purchases come at 0% fee when people cash ou at the machine.<br>
|
||||
<strong>Fixed Mode:</strong> Set a daily limit for consistent Bitcoin accumulation.
|
||||
</div>
|
||||
</q-banner>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disable="!registrationForm.selectedWallet || !registrationForm.dca_mode"
|
||||
class="full-width"
|
||||
size="lg"
|
||||
>
|
||||
<q-icon name="flash_on" class="q-mr-sm"></q-icon>
|
||||
Start My DCA Journey 🚀
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
|
|
@ -179,7 +247,7 @@
|
|||
<div v-if="dashboardData.pending_fiat_deposits > 0" class="q-mt-md">
|
||||
<q-banner rounded class="bg-orange-1 text-orange-9">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="schedule" color="orange" size="md" />
|
||||
<q-icon name="schedule" color="orange" size="md"></q-icon>
|
||||
</template>
|
||||
<div class="text-subtitle2">
|
||||
⏳ <strong>${formatCurrency(dashboardData.pending_fiat_deposits)}</strong> ready to DCA
|
||||
|
|
@ -363,7 +431,7 @@
|
|||
<template v-slot:no-data="{ message }">
|
||||
<div class="full-width row flex-center q-pa-lg">
|
||||
<div class="text-center">
|
||||
<q-icon name="rocket_launch" size="3em" class="text-orange-5 q-mb-md" />
|
||||
<q-icon name="rocket_launch" size="3em" class="text-orange-5 q-mb-md"></q-icon>
|
||||
<div class="text-h6 text-grey-7 q-mb-sm">${message}</div>
|
||||
<div class="text-caption text-grey-5">
|
||||
Visit your nearest Lamassu ATM to begin stacking sats automatically
|
||||
|
|
@ -443,6 +511,17 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 50000" class="text-positive text-h6">✅</div>
|
||||
<div v-else class="text-grey-5 text-h6">⭕</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">50,000 sats</q-item-label>
|
||||
<q-item-label caption>Lightning quick ⚡</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 100000" class="text-positive text-h6">✅</div>
|
||||
|
|
@ -472,7 +551,51 @@
|
|||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">1,000,000 sats</q-item-label>
|
||||
<q-item-label caption>True HODLer 💎</q-item-label>
|
||||
<q-item-label caption>Millionaire! 🌟</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 5000000" class="text-positive text-h6">✅</div>
|
||||
<div v-else class="text-grey-5 text-h6">⭕</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">5,000,000 sats</q-item-label>
|
||||
<q-item-label caption>Verified Bag Holder 🏆</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 10000000" class="text-positive text-h6">✅</div>
|
||||
<div v-else class="text-grey-5 text-h6">⭕</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">10,000,000 sats</q-item-label>
|
||||
<q-item-label caption>Bitcoin royalty 👑</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 50000000" class="text-positive text-h6">✅</div>
|
||||
<div v-else class="text-grey-5 text-h6">⭕</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">50,000,000 sats</q-item-label>
|
||||
<q-item-label caption>Bitcoin baron 🎆</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item>
|
||||
<q-item-section avatar>
|
||||
<div v-if="dashboardData.total_sats_accumulated >= 100000000" class="text-positive text-h6">✅</div>
|
||||
<div v-else class="text-grey-5 text-h6">⭕</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-body2">100,000,000 sats</q-item-label>
|
||||
<q-item-label caption>Legendary Full coiner! 🏆</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
|
|
@ -523,106 +646,5 @@
|
|||
</q-card>
|
||||
</div>
|
||||
|
||||
<!-- Registration Dialog -->
|
||||
<q-dialog v-model="showRegistrationDialog" persistent position="top">
|
||||
<q-card class="q-pa-lg" style="width: 500px; max-width: 90vw">
|
||||
<div class="text-center q-mb-lg">
|
||||
<q-icon name="account_circle" size="4em" color="orange" />
|
||||
<div class="text-h5 q-mt-md text-orange-8">Welcome to DCA!</div>
|
||||
<div class="text-body2 text-grey-7">Let's set up your Bitcoin Dollar Cost Averaging account</div>
|
||||
</div>
|
||||
|
||||
<q-form @submit="registerClient" class="q-gutter-md">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="registrationForm.username"
|
||||
label="Username (Optional)"
|
||||
placeholder="Enter a friendly name"
|
||||
hint="How you'd like to be identified in the system"
|
||||
/>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="registrationForm.selectedWallet"
|
||||
:options="g.user.wallets"
|
||||
option-label="name"
|
||||
label="DCA Wallet *"
|
||||
hint="Choose which wallet will receive your Bitcoin DCA purchases"
|
||||
>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
${scope.opt.name} (ID: ${scope.opt.id.substring(0, 8)}... • ${Math.round(scope.opt.balance_msat / 1000)} sats)
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<q-select
|
||||
filled
|
||||
dense
|
||||
v-model="registrationForm.dca_mode"
|
||||
:options="[
|
||||
{ label: 'Flow Mode (Recommended)', value: 'flow', description: 'Proportional distribution based on your balance' },
|
||||
{ label: 'Fixed Mode', value: 'fixed', description: 'Set daily purchase limits' }
|
||||
]"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
label="DCA Strategy *"
|
||||
hint="Choose how your Bitcoin purchases will be distributed"
|
||||
>
|
||||
<template v-slot:option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section>
|
||||
<q-item-label>${scope.opt.label} - ${scope.opt.description}</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
|
||||
<q-input
|
||||
v-if="registrationForm.dca_mode === 'fixed'"
|
||||
filled
|
||||
dense
|
||||
type="number"
|
||||
v-model.number="registrationForm.fixed_mode_daily_limit"
|
||||
label="Daily Limit (GTQ)"
|
||||
placeholder="Enter daily purchase limit in centavos"
|
||||
hint="Maximum amount to purchase per day (in centavos: 1 GTQ = 100 centavos)"
|
||||
:rules="[
|
||||
val => registrationForm.dca_mode !== 'fixed' || (val && val > 0) || 'Daily limit is required for fixed mode'
|
||||
]"
|
||||
/>
|
||||
|
||||
<q-banner class="bg-blue-1 text-blue-9 q-mt-md">
|
||||
<template v-slot:avatar>
|
||||
<q-icon name="info" color="blue" />
|
||||
</template>
|
||||
<div class="text-caption">
|
||||
<strong>Flow Mode:</strong> Your Bitcoin purchases are proportional to your deposit balance.<br>
|
||||
<strong>Fixed Mode:</strong> Set a daily limit for consistent Bitcoin accumulation.
|
||||
</div>
|
||||
</q-banner>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
type="submit"
|
||||
:disable="!registrationForm.selectedWallet || !registrationForm.dca_mode"
|
||||
class="full-width"
|
||||
size="lg"
|
||||
>
|
||||
<q-icon name="rocket_launch" class="q-mr-sm" />
|
||||
Start My DCA Journey
|
||||
</q-btn>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ async def api_export_transactions(
|
|||
writer.writerow([
|
||||
tx.created_at.isoformat(),
|
||||
tx.amount_sats,
|
||||
tx.amount_fiat, # Values are already in full currency units
|
||||
tx.amount_fiat, # Amount already in GTQ
|
||||
tx.exchange_rate,
|
||||
tx.transaction_type,
|
||||
tx.status
|
||||
|
|
@ -214,3 +214,7 @@ async def api_export_transactions(
|
|||
)
|
||||
else:
|
||||
return {"transactions": transactions}
|
||||
|
||||
|
||||
# Removed local client-limits endpoint
|
||||
# Client should call admin extension's public endpoint directly
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue