diff --git a/config.json b/config.json index dc18aab..3832789 100644 --- a/config.json +++ b/config.json @@ -38,6 +38,11 @@ "name": "arcbtc", "uri": "https://github.com/arcbtc", "role": "Developer" + }, + { + "name": "blackcoffee", + "uri": "https://github.com/blackcoffeexbt", + "role": "Developer" } ], "images": [ diff --git a/static/css/index.css b/static/css/index.css new file mode 100644 index 0000000..befe770 --- /dev/null +++ b/static/css/index.css @@ -0,0 +1,573 @@ +.q-avatar { + width: 1.2em; + height: 1.2em; + } + .q-stepper__dot { + width: 42px; + height: 42px; + } + .q-stepper__content { + display: none; + } +.flow-chart-container { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + background: var(--q-color-grey-3); + border-radius: 12px; + margin: 16px 0; + min-height: 400px; +} + +.body--dark .flow-chart-container { + background: var(--q-color-grey-9); +} + +.flow-chart { + width: 100%; + height: 100%; + max-width: 600px; + min-height: 400px; +} + +@media (max-width: 600px) { + .flow-chart-container { + display: none; + } +} + +/* Step 1 Mobile Improvements */ +.step-card { + margin-bottom: 16px; +} + +.step-section { + padding: 16px; +} + +.step-title { + font-size: 1.5rem; + line-height: 1.2; +} + +.step-description { + font-size: 1rem; + line-height: 1.4; +} + +/* Wallet selection improvements */ +.wallet-option { + cursor: pointer; + transition: all 0.2s ease; + min-height: 60px; +} + +.wallet-option:hover { + background-color: #f5f5f5; +} + +.wallet-option.selected { + border-color: #1976d2; + background-color: #e3f2fd; +} + +.wallet-option .q-radio { + min-height: 48px; +} + +/* Info banner improvements */ +.info-banner { + margin-top: 16px; +} + +/* Continue button improvements */ +.continue-button-container { + padding: 16px 0; +} + +.continue-button { + min-height: 48px; + padding: 0 24px; +} + +/* Mobile-specific styles */ +@media (max-width: 768px) { + .step-section { + padding: 12px; + } + + .step-title { + font-size: 1.25rem; + margin-bottom: 8px; + } + + .step-description { + font-size: 0.875rem; + margin-bottom: 16px; + } + + .wallet-option { + min-height: 64px; + } + + .wallet-option .q-card-section { + padding: 16px; + } + + .continue-button { + width: 100%; + min-height: 56px; + font-size: 1rem; + } + + .continue-button-container { + padding: 20px 0; + } + + .info-banner { + margin-top: 12px; + padding: 12px; + } + + /* Stepper improvements on mobile */ + .q-stepper--vertical .q-stepper__step { + padding: 8px 0; + } + + .q-stepper--vertical .q-stepper__dot { + width: 32px; + height: 32px; + } + + /* Reduce wizard header padding on mobile */ + .step-card .q-card-section:first-child { + padding-top: 8px; + } +} + +/* Step 2 Mobile Improvements */ +.split-recipient-container { + border-left: 3px solid #1976d2; + padding-left: 16px; + margin-bottom: 24px; + position: relative; +} + +.split-recipient-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.split-number-circle { + width: 28px; + height: 28px; + border-radius: 50%; + background-color: #1976d2; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 14px; + flex-shrink: 0; +} + +.split-header-text { + font-size: 1rem; + font-weight: 500; + color: #374151; + flex: 1; +} + +.remove-btn { + opacity: 0.7; + transition: opacity 0.2s ease; +} + +.remove-btn:hover { + opacity: 1; +} + +.split-fields { + display: flex; + flex-direction: column; + gap: 12px; +} + +.split-field { + width: 100%; +} + +.split-field .q-field__control { + min-height: 48px; +} + +.add-recipient-container { + display: flex; + justify-content: center; + padding: 16px 0; +} + +.add-recipient-btn { + min-height: 48px; + padding: 0 24px; +} + +.step-navigation { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 16px 0; +} + +.back-button { + min-height: 48px; + padding: 0 20px; +} + +/* Mobile-specific styles for Step 2 */ +@media (max-width: 768px) { + .split-recipient-container { + border-left: 2px solid #1976d2; + padding-left: 12px; + margin-bottom: 20px; + } + + .split-recipient-header { + gap: 8px; + margin-bottom: 12px; + } + + .split-number-circle { + width: 24px; + height: 24px; + font-size: 12px; + } + + .split-header-text { + font-size: 0.875rem; + } + + .split-fields { + gap: 8px; + } + + .split-field .q-field__control { + min-height: 52px; + } + + .add-recipient-btn { + width: 100%; + min-height: 56px; + font-size: 1rem; + } + + .step-navigation { + flex-direction: column; + gap: 12px; + padding: 20px 0; + } + + .back-button { + width: 100%; + min-height: 52px; + order: 2; + } + + .continue-button { + width: 100%; + min-height: 56px; + order: 1; + } + + /* Improve percentage badge layout on mobile */ + .items-center.q-gutter-md { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .items-center.q-gutter-md .q-badge { + align-self: flex-start; + } +} + +/* Step 3 Mobile Improvements */ +.summary-cards-container { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Ensure consistent font family for all Step 3 elements */ +.summary-card-title, +.summary-card-main-text, +.summary-card-sub-text, +.target-name, +.target-wallet { + font-family: Roboto, "-apple-system", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +.summary-card { + border-radius: 8px; + overflow: hidden; +} + +.summary-card-source { + background: var(--q-color-blue-1); +} + +.summary-card-targets { + background: var(--q-color-green-1); +} + +.body--dark .summary-card-source { + background: var(--q-color-blue-10); +} + +.body--dark .summary-card-targets { + background: var(--q-color-green-10); +} + +.summary-card-section { + padding: 16px; +} + +.summary-card-header { + display: flex; + align-items: center; + margin-bottom: 12px; + color: var(--q-color-primary); +} + +.summary-card-source .summary-card-header { + color: var(--q-color-blue-8); +} + +.summary-card-targets .summary-card-header { + color: var(--q-color-green-8); +} + +.body--dark .summary-card-source .summary-card-header { + color: var(--q-color-blue-4); +} + +.body--dark .summary-card-targets .summary-card-header { + color: var(--q-color-green-4); +} + +.summary-card-title { + margin-left: 10px; + margin-top: -5px; + font-weight: 600; + line-height: 1.2; +} + +.summary-card-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.summary-card-main-text { + font-size: 1.125rem; + font-weight: 700; + color: var(--q-color-primary); +} + +.summary-card-source .summary-card-main-text { + color: var(--q-color-blue-8); +} + +.summary-card-targets .summary-card-main-text { + color: var(--q-color-green-8); +} + +.body--dark .summary-card-source .summary-card-main-text { + color: var(--q-color-blue-4); +} + +.body--dark .summary-card-targets .summary-card-main-text { + color: var(--q-color-green-4); +} + +.summary-card-sub-text { + font-size: 0.875rem; + color: var(--q-color-grey-7); +} + +.body--dark .summary-card-sub-text { + color: var(--q-color-grey-5); +} + + +.detailed-targets-container { + margin-bottom: 24px; +} + +.targets-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.target-summary-item { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + background: var(--q-color-grey-1); + border-radius: 8px; + border: 1px solid var(--q-color-grey-4); +} + +.body--dark .target-summary-item { + background: var(--q-color-grey-9); + border-color: var(--q-color-grey-7); +} + +.target-percent-circle { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: #059669; + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1rem; + flex-shrink: 0; +} + +.target-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.target-name { + font-size: 1rem; + font-weight: 600; + color: var(--q-color-grey-9); +} + +.body--dark .target-name { + color: var(--q-color-grey-3); +} + +.target-wallet { + font-size: 0.875rem; + color: var(--q-color-grey-7); + word-break: break-all; +} + +.body--dark .target-wallet { + color: var(--q-color-grey-5); +} + +.step-3-navigation { + justify-content: center; + gap: 16px; +} + +.confirm-button { + background: #059669 !important; + color: white !important; +} + +.confirm-button:disabled { + background: #9ca3af !important; +} + +/* Mobile-specific styles for Step 3 */ +@media (max-width: 768px) { + .summary-cards-container { + gap: 12px; + } + + .summary-card-section { + padding: 12px; + } + + .summary-card-header { + margin-bottom: 8px; + } + + .summary-card-title { + font-size: 0.875rem; + line-height: 1.2; + } + + .summary-card-main-text { + font-size: 1rem; + } + + .summary-card-sub-text { + font-size: 0.75rem; + } + + .targets-list { + gap: 8px; + } + + .target-summary-item { + padding: 12px; + gap: 12px; + } + + .target-percent-circle { + width: 48px; + height: 48px; + font-size: 0.875rem; + } + + .target-name { + font-size: 0.875rem; + } + + .target-wallet { + font-size: 0.75rem; + } + + .step-3-navigation { + flex-direction: column; + gap: 12px; + } + + .back-button { + width: 100%; + min-height: 52px; + order: 2; + } + + .confirm-button { + width: 100%; + min-height: 56px; + order: 1; + font-size: 1rem; + } + + /* Flow chart container mobile optimization */ + .flow-chart-container { + margin: 12px 0; + padding: 12px; + min-height: 300px; + } + + /* Status banners mobile optimization */ + .q-banner { + padding: 12px; + margin-bottom: 12px; + } + + .q-banner .q-banner__content { + font-size: 0.875rem; + line-height: 1.4; + } +} \ No newline at end of file diff --git a/static/image/bitcoin-logo.svg b/static/image/bitcoin-logo.svg new file mode 100644 index 0000000..c23cb37 --- /dev/null +++ b/static/image/bitcoin-logo.svg @@ -0,0 +1,9 @@ + + + Shape + + + + \ No newline at end of file diff --git a/static/image/icon-wallet.png b/static/image/icon-wallet.png new file mode 100644 index 0000000..5a19515 Binary files /dev/null and b/static/image/icon-wallet.png differ diff --git a/static/js/index.js b/static/js/index.js index ca8462b..0c13e49 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -16,24 +16,219 @@ function isTargetComplete(target) { window.app = Vue.createApp({ el: '#vue', mixins: [windowMixin], + components: { + 'split-payments-chart': SplitPaymentsChart + }, watch: { selectedWallet() { this.getTargets() - } + }, }, data() { return { + // Wizard state + currentStep: 1, + maxSteps: 3, + + // Existing data selectedWallet: null, currentHash: '', // a string that must match if the edit data is unchanged - targets: [] + targets: [], + walletSplits: {}, // Store split data for each wallet + showSavedConfirmation: false, // Show confirmation after saving + lastSavedTargetCount: 0 // Track number of targets that were saved } }, computed: { + // Step validation + canProceedFromStep1() { + return this.selectedWallet !== null + }, + canProceedFromStep2() { + return this.targets.length > 0 && this.totalPercent <= 100 && this.allTargetsValid + }, + totalPercent() { + return this.targets.reduce((sum, target) => sum + (target.percent || 0), 0) + }, + remainingPercent() { + return Math.max(0, 100 - this.totalPercent) + }, + allTargetsValid() { + return this.targets.every(target => + target.wallet && target.wallet.trim() !== '' && + target.percent > 0 && target.percent <= 100 && + target.alias && target.alias.trim() !== '' && target.alias.trim().length <= 50 + ) && !this.hasDuplicateRecipients && !this.hasDuplicateNames + }, + hasValidationErrors() { + return this.targets.some(target => + !target.wallet || target.wallet.trim() === '' || + !target.alias || target.alias.trim() === '' || + target.percent <= 0 || target.percent > 100 + ) || this.hasDuplicateRecipients || this.hasDuplicateNames + }, + hasDuplicateRecipients() { + const walletAddresses = this.targets + .filter(target => target.wallet && target.wallet.trim() !== '') + .map(target => target.wallet.trim().toLowerCase()) + + return walletAddresses.length !== new Set(walletAddresses).size + }, + hasDuplicateNames() { + const splitNames = this.targets + .filter(target => target.alias && target.alias.trim() !== '') + .map(target => target.alias.trim().toLowerCase()) + + return splitNames.length !== new Set(splitNames).size + }, + validationSummary() { + const errors = [] + if (this.targets.length === 0) { + errors.push('At least one split target is required') + } + if (this.totalPercent > 100) { + errors.push(`Total percentage (${this.totalPercent}%) exceeds 100%`) + } + if (this.hasDuplicateRecipients) { + errors.push('Duplicate recipient addresses found - each recipient must be unique') + } + if (this.hasDuplicateNames) { + errors.push('Duplicate split names found - each split name must be unique') + } + if (this.hasValidationErrors && !this.hasDuplicateRecipients && !this.hasDuplicateNames) { + errors.push('Some fields have validation errors') + } + return errors + }, + showPercentWarning() { + return this.totalPercent > 90 && this.totalPercent < 100 + }, + showPercentError() { + return this.totalPercent > 100 + }, + // Split diagram data + splitDiagramData() { + const data = [] + + // Add target wallets + this.targets.forEach(target => { + if (target.percent > 0 && target.alias) { + data.push({ + name: target.alias, + percent: target.percent, + type: 'target', + color: '#43a047' + }) + } + }) + + // Add source wallet (remaining percentage or 100% if no targets) + const remainingPercent = this.targets.length > 0 ? this.remainingPercent : 100 + if (remainingPercent > 0) { + data.push({ + name: this.selectedWallet ? this.selectedWallet.name : 'Source', + percent: remainingPercent, + type: 'source', + color: '#1976d2' + }) + } + + return data.sort((a, b) => b.percent - a.percent) + }, isDirty() { return hashTargets(this.targets) !== this.currentHash + }, + + // Get split summaries for all wallets + walletSplitSummaries() { + const summaries = {} + + for (const walletId in this.walletSplits) { + const splits = this.walletSplits[walletId] + if (splits && splits.length > 0) { + const totalPercent = splits.reduce((sum, split) => sum + (split.percent || 0), 0) + const remainingPercent = Math.max(0, 100 - totalPercent) + + summaries[walletId] = { + totalPercent, + remainingPercent, + splitCount: splits.length, + splits: splits.slice(0, 3) // Show first 3 splits + } + } + } + + return summaries } }, methods: { + // Wizard navigation + nextStep() { + if (this.currentStep < this.maxSteps) { + if (this.currentStep === 1 && this.canProceedFromStep1) { + this.showSavedConfirmation = false // Hide confirmation when proceeding + this.currentStep++ + this.scrollToTop() + } else if (this.currentStep === 2 && this.canProceedFromStep2) { + this.currentStep++ + this.scrollToTop() + } + } + }, + prevStep() { + if (this.currentStep > 1) { + this.currentStep-- + this.scrollToTop() + } + }, + goToStep(step) { + if (step >= 1 && step <= this.maxSteps) { + this.currentStep = step + this.scrollToTop() + } + }, + + // Scroll to top of wizard + scrollToTop() { + this.$nextTick(() => { + // Find the wizard container (the stepper card) + const wizardElement = document.querySelector('.q-stepper') || document.querySelector('.q-card') + if (wizardElement) { + wizardElement.scrollIntoView({ + behavior: 'smooth', + block: 'start', + inline: 'nearest' + }) + } else { + // Fallback to window scroll + window.scrollTo({ + top: 0, + behavior: 'smooth' + }) + } + }) + }, + + // Validation helper methods + isDuplicateRecipient(index) { + const currentWallet = this.targets[index]?.wallet?.trim().toLowerCase() + if (!currentWallet) return false + + return this.targets.some((target, i) => + i !== index && target.wallet?.trim().toLowerCase() === currentWallet + ) + }, + isDuplicateName(index) { + const currentName = this.targets[index]?.alias?.trim().toLowerCase() + if (!currentName) return false + + return this.targets.some((target, i) => + i !== index && target.alias?.trim().toLowerCase() === currentName + ) + }, + + + // Target management methods clearTarget(index) { if (this.targets.length == 1) { return this.deleteTargets() @@ -63,9 +258,37 @@ window.app = Vue.createApp({ this.getTargets() }, addTarget() { - this.targets.push({source: this.selectedWallet}) + this.targets.push({ + source: this.selectedWallet, + alias: '', + wallet: '', + percent: 0 + }) }, saveTargets() { + // Final validation before saving + if (this.validationSummary.length > 0) { + Quasar.Notify.create({ + message: 'Please fix validation errors before saving.', + timeout: 3000, + color: 'negative', + icon: 'error' + }) + return + } + + if (!this.selectedWallet) { + Quasar.Notify.create({ + message: 'Please select a source wallet.', + timeout: 3000, + color: 'negative', + icon: 'error' + }) + this.currentStep = 1 + this.scrollToTop() + return + } + LNbits.api .request( 'PUT', @@ -77,12 +300,37 @@ window.app = Vue.createApp({ ) .then(response => { Quasar.Notify.create({ - message: 'Split payments targets set.', - timeout: 700 + message: `Split payments activated! ${this.targets.length} target${this.targets.length !== 1 ? 's' : ''} configured.`, + timeout: 5000, + color: 'positive', + icon: 'check_circle', + actions: [ + { + label: 'Dismiss', + color: 'white', + handler: () => {} + } + ] }) + // Update hash to reflect saved state + this.currentHash = hashTargets(this.targets) + // Update wallet splits data + this.walletSplits[this.selectedWallet.id] = [...this.targets] + // Show confirmation banner on step 1 + this.showSavedConfirmation = true + this.lastSavedTargetCount = this.targets.length + // Reset to step 1 after successful save + this.currentStep = 1 + this.scrollToTop() }) .catch(err => { LNbits.utils.notifyApiError(err) + Quasar.Notify.create({ + message: 'Failed to save split payment configuration. Please try again.', + timeout: 5000, + color: 'negative', + icon: 'error' + }) }) }, deleteTargets() { @@ -106,9 +354,44 @@ window.app = Vue.createApp({ LNbits.utils.notifyApiError(err) }) }) + }, + + async checkExistingConfigurations() { + let firstWalletWithSplits = null + + // Check each wallet for existing split payment configurations + for (const wallet of this.g.user.wallets) { + try { + const response = await LNbits.api.request( + 'GET', + '/splitpayments/api/v1/targets', + wallet.adminkey + ) + if (response.data && response.data.length > 0) { + // Store split data for this wallet + this.walletSplits[wallet.id] = response.data + + // Remember the first wallet with splits + if (!firstWalletWithSplits) { + firstWalletWithSplits = wallet + } + } + } catch (err) { + // Wallet has no configuration, continue checking others + continue + } + } + + // Select first wallet with splits, or first wallet if none have splits + if (firstWalletWithSplits) { + this.selectedWallet = firstWalletWithSplits + this.getTargets() + } else if (this.g.user.wallets.length > 0) { + this.selectedWallet = this.g.user.wallets[0] + } } }, - created() { - this.selectedWallet = this.g.user.wallets[0] - } + mounted() { + this.checkExistingConfigurations() + }, }) diff --git a/static/js/split-payments-chart.js b/static/js/split-payments-chart.js new file mode 100644 index 0000000..dbfed9a --- /dev/null +++ b/static/js/split-payments-chart.js @@ -0,0 +1,334 @@ +// Split Payments Flow Chart Component +window.SplitPaymentsChart = Vue.defineComponent({ + name: 'SplitPaymentsChart', + props: { + splitDiagramData: { + type: Array, + required: true + }, + selectedWallet: { + type: Object, + default: null + }, + remainingPercent: { + type: Number, + default: 0 + } + }, + mounted() { + this.createFlowChart() + }, + watch: { + splitDiagramData: { + handler() { + this.$nextTick(() => { + this.createFlowChart() + }) + }, + deep: true + } + }, + methods: { + createFlowChart() { + try { + const container = this.$refs.chartContainer + if (!container) { + console.warn('Chart container not found') + return + } + + // Clear previous content + container.innerHTML = '' + + // Detect dark theme + const isDarkTheme = document.body.classList.contains('body--dark') + + // Create SVG element + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + svg.setAttribute('width', '100%') + svg.setAttribute('height', '500') + svg.setAttribute('viewBox', '0 0 400 450') + svg.style.background = 'transparent' + + // Get targets data and source data + const targets = this.splitDiagramData.filter(item => item.type === 'target') + const sourceRemaining = this.splitDiagramData.filter(item => item.type === 'source') + + if (targets.length === 0 && sourceRemaining.length === 0) { + container.appendChild(svg) + return + } + + // Define positions + const sourceX = 200 + const sourceY = 80 + const targetY = 320 + + // Calculate bottom row items (targets + source if remaining > 0) + const bottomRowItems = [...targets] + if (sourceRemaining.length > 0 && this.remainingPercent > 0) { + bottomRowItems.push({ + name: this.selectedWallet ? this.selectedWallet.name : 'Source', + percent: this.remainingPercent, + type: 'source_remaining', + color: '#96A6FF' + }) + } + + // Calculate positions for bottom row items + const bottomRowPositions = [] + if (bottomRowItems.length === 1) { + bottomRowPositions.push({ x: sourceX, y: targetY }) + } else { + // Use the full width of the SVG viewBox (400px) with padding + const padding = 10 // Padding from edges + const totalWidth = 400 - (padding * 2) // Available width + const spacing = totalWidth / (bottomRowItems.length - 1) + const startX = padding + + bottomRowItems.forEach((item, index) => { + bottomRowPositions.push({ x: startX + (index * spacing), y: targetY }) + }) + } + + // Calculate proportional line thickness + const maxPercent = Math.max(...bottomRowItems.map(t => t.percent)) + const maxThickness = 30 // Maximum line thickness in pixels + + // Draw flowing lines - source_remaining lines first (behind other lines) + // First pass: draw source_remaining lines + bottomRowItems.forEach((item, index) => { + if (item.type === 'source_remaining') { + const itemPos = bottomRowPositions[index] + // Calculate thickness proportional to the highest percentage + const lineThickness = Math.max(3, (item.percent / maxPercent) * maxThickness) + + // End the line before the wallet icon (30px is wallet icon radius) + const lineEndY = targetY - 45 + this.drawFlowingLine(svg, sourceX, sourceY + 35, itemPos.x, lineEndY, lineThickness, item.color || '#4ade80') + } + }) + + // Second pass: draw target lines (on top of source_remaining lines) + bottomRowItems.forEach((item, index) => { + if (item.type === 'target') { + const itemPos = bottomRowPositions[index] + // Calculate thickness proportional to the highest percentage + const lineThickness = Math.max(3, (item.percent / maxPercent) * maxThickness) + + // End the line before the wallet icon (30px is wallet icon radius) + const lineEndY = targetY - 45 + this.drawFlowingLine(svg, sourceX, sourceY + 35, itemPos.x, lineEndY, lineThickness, item.color || '#4ade80') + } + }) + + // Draw source Bitcoin logo + this.drawBitcoinLogo(svg, sourceX, sourceY, isDarkTheme) + + // Draw bottom row wallet icons + bottomRowItems.forEach((item, index) => { + const itemPos = bottomRowPositions[index] + if (item.type === 'source_remaining') { + this.drawWalletIcon(svg, itemPos.x, itemPos.y, 'source_remaining', item.percent, item.name, isDarkTheme) + } else { + this.drawWalletIcon(svg, itemPos.x, itemPos.y, 'target', item.percent, item.name, isDarkTheme) + } + }) + + container.appendChild(svg) + console.log('Flow chart created successfully') + } catch (error) { + console.error('Error creating flow chart:', error) + } + }, + + drawFlowingLine(svg, x1, y1, x2, y2, finalThickness, color) { + // Create a tapered line that starts at 10px and increases to finalThickness + const startThickness = 10 + const segments = 20 // Number of segments for smooth taper + const midY = y1 + (y2 - y1) * 0.6 + + // Generate points along the quadratic Bezier curve + const points = [] + for (let i = 0; i <= segments; i++) { + const t = i / segments + let x, y + + if (t <= 0.5) { + // First quadratic curve: (x1, y1) to ((x1+x2)/2, midY) + const localT = t * 2 + const p0 = {x: x1, y: y1} + const p1 = {x: x1, y: midY} + const p2 = {x: (x1 + x2) / 2, y: midY} + + x = (1 - localT) * (1 - localT) * p0.x + 2 * (1 - localT) * localT * p1.x + localT * localT * p2.x + y = (1 - localT) * (1 - localT) * p0.y + 2 * (1 - localT) * localT * p1.y + localT * localT * p2.y + } else { + // Second quadratic curve: ((x1+x2)/2, midY) to (x2, y2) + const localT = (t - 0.5) * 2 + const p0 = {x: (x1 + x2) / 2, y: midY} + const p1 = {x: x2, y: midY} + const p2 = {x: x2, y: y2} + + x = (1 - localT) * (1 - localT) * p0.x + 2 * (1 - localT) * localT * p1.x + localT * localT * p2.x + y = (1 - localT) * (1 - localT) * p0.y + 2 * (1 - localT) * localT * p1.y + localT * localT * p2.y + } + + // Calculate thickness at this point + const thickness = startThickness + (finalThickness - startThickness) * t + points.push({x, y, thickness}) + } + + // Create polygon points for the tapered line + const leftPoints = [] + const rightPoints = [] + + for (let i = 0; i < points.length; i++) { + const point = points[i] + const halfThickness = point.thickness / 2 + + // Calculate direction vector + let dx = 0, dy = 1 + if (i < points.length - 1) { + dx = points[i + 1].x - point.x + dy = points[i + 1].y - point.y + } else if (i > 0) { + dx = point.x - points[i - 1].x + dy = point.y - points[i - 1].y + } + + // Normalize direction vector + const length = Math.sqrt(dx * dx + dy * dy) + if (length > 0) { + dx /= length + dy /= length + } + + // Calculate perpendicular offset (rotate 90 degrees) + const perpX = -dy + const perpY = dx + + // Add points to left and right sides + leftPoints.push({ + x: point.x + perpX * halfThickness, + y: point.y + perpY * halfThickness + }) + rightPoints.unshift({ + x: point.x - perpX * halfThickness, + y: point.y - perpY * halfThickness + }) + } + + // Create arrow tip pointing down + const lastPoint = points[points.length - 1] + const arrowHeight = 15 // Fixed arrow height so all arrows terminate at same Y position + + // Get the last left and right points to connect seamlessly + const lastLeftPoint = leftPoints[leftPoints.length - 1] + const lastRightPoint = rightPoints[0] // rightPoints is reversed, so first element is the last point + + // Arrow tip points - connect directly to the line ends + const arrowTip = {x: lastPoint.x, y: lastPoint.y + arrowHeight} + + // Create combined polygon including line body and arrow + const allPoints = [ + ...leftPoints.slice(0, -1), // All left points except the last one + lastLeftPoint, // Last left point + arrowTip, // Arrow tip + lastRightPoint, // Last right point + ...rightPoints.slice(1) // All right points except the first one + ] + + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon') + const pointsString = allPoints.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ') + + polygon.setAttribute('points', pointsString) + polygon.setAttribute('fill', color) + polygon.setAttribute('opacity', '1') + + svg.appendChild(polygon) + }, + + drawWalletIcon(svg, x, y, type, percentage, targetName = null, isDarkTheme = false) { + // Create wallet icon using the PNG image + const image = document.createElementNS('http://www.w3.org/2000/svg', 'image') + image.setAttribute('x', x - 30) + image.setAttribute('y', y - 30) + image.setAttribute('width', 60) + image.setAttribute('height', 60) + + image.setAttribute('href', '/splitpayments/static/image/icon-wallet.png') + + // Add color filter for source vs target distinction + if (type === 'source' || type === 'source_remaining') { + // Add blue tint for source wallet + image.setAttribute('style', 'filter: hue-rotate(200deg) saturate(1.2)') + } + + // Add error handling - if image fails to load, show a fallback + image.addEventListener('error', () => { + console.warn('Failed to load wallet icon, using fallback') + // Remove the broken image and replace with a styled rectangle + svg.removeChild(image) + }) + + svg.appendChild(image) + + // Add name and percentage below icon for targets and source_remaining + if (type === 'target' || type === 'source_remaining') { + // Add name text + if (targetName) { + const nameText = document.createElementNS('http://www.w3.org/2000/svg', 'text') + nameText.setAttribute('x', x) + nameText.setAttribute('y', y + 55) + nameText.setAttribute('text-anchor', 'middle') + nameText.setAttribute('fill', type === 'source_remaining' ? '#96A6FF' : (isDarkTheme ? '#f3f4f6' : '#374151')) + nameText.setAttribute('class', 'text-body2') + nameText.textContent = targetName + + svg.appendChild(nameText) + } + + // Add percentage text below name + const percentText = document.createElementNS('http://www.w3.org/2000/svg', 'text') + percentText.setAttribute('x', x) + percentText.setAttribute('y', y + 85) + percentText.setAttribute('text-anchor', 'middle') + percentText.setAttribute('fill', type === 'source_remaining' ? '#96A6FF' : '#f59e0b') + percentText.setAttribute('class', 'text-h5') + percentText.textContent = `${percentage}%` + + svg.appendChild(percentText) + } + }, + + drawBitcoinLogo(svg, x, y, isDarkTheme = false) { + // Create Bitcoin logo using SVG + const logoGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g') + + const bitcoinPath = document.createElementNS('http://www.w3.org/2000/svg', 'path') + bitcoinPath.setAttribute('d', 'M39.0674606,19.3675957 L40.5054606,13.5995957 L36.9944606,12.7245957 L35.5944606,18.3405957 C34.6714606,18.1105957 33.7234606,17.8935957 32.7814606,17.6785957 L34.1914606,12.0255957 L30.6824606,11.1505957 L29.2434606,16.9165957 C28.4794606,16.7425957 27.7294606,16.5705957 27.0014606,16.3895957 L27.0054606,16.3715957 L22.1634606,15.1625957 L21.2294606,18.9125957 C21.2294606,18.9125957 23.8344606,19.5095957 23.7794606,19.5465957 C25.2014606,19.9015957 25.4584606,20.8425957 25.4154606,21.5885957 L23.7774606,28.1595957 L23.7714606,28.1845957 L21.4754606,37.3895957 C21.3014606,37.8215957 20.8604606,38.4695957 19.8664606,38.2235957 C19.9014606,38.2745957 17.3144606,37.5865957 17.3144606,37.5865957 L15.5714606,41.6055957 L20.1404606,42.7445957 C20.9904606,42.9575957 21.8234606,43.1805957 22.6434606,43.3905957 L21.1904606,49.2245957 L24.6974606,50.0995957 L26.1364606,44.3275957 C27.0944606,44.5875957 28.0244606,44.8275957 28.9344606,45.0535957 L27.5004606,50.7985957 L31.0114606,51.6735957 L32.4644606,45.8505957 C38.4514606,46.9835957 42.9534606,46.5265957 44.8484606,41.1115957 C46.3754606,36.7515957 44.7724606,34.2365957 41.6224606,32.5965957 C43.9164606,32.0675957 45.6444606,30.5585957 46.1054606,27.4415957 C46.7424606,23.1835957 43.5004606,20.8945957 39.0674606,19.3675957 Z M38.0834606,38.6905957 C36.9984606,43.0505957 29.6574606,40.6935957 27.2774606,40.1025957 L29.2054606,32.3735957 C31.5854606,32.9675957 39.2174606,34.1435957 38.0834606,38.6905957 Z M39.1694606,27.3785957 C38.1794606,31.3445957 32.0694606,29.3295957 30.0874606,28.8355957 L31.8354606,21.8255957 C33.8174606,22.3195957 40.2004606,23.2415957 39.1694606,27.3785957 Z') + bitcoinPath.setAttribute('fill', '#f7931a') // Orange color for Bitcoin + bitcoinPath.setAttribute('transform', `translate(${x - 45}, ${y - 50}) scale(1.4)`) // Scale and position the logo + + logoGroup.appendChild(bitcoinPath) + svg.appendChild(logoGroup) + + // Add "Incoming Payment" text above the Bitcoin logo + const incomingText = document.createElementNS('http://www.w3.org/2000/svg', 'text') + incomingText.setAttribute('x', x) + incomingText.setAttribute('y', y - 50) + incomingText.setAttribute('text-anchor', 'middle') + incomingText.setAttribute('fill', isDarkTheme ? '#f9ca24' : '#f7931a') + incomingText.textContent = 'Incoming Payment' + + svg.appendChild(incomingText) + } + }, + + template: ` +
+
+
+ ` +}) \ No newline at end of file diff --git a/templates/splitpayments/index.html b/templates/splitpayments/index.html index b1dd9dd..8650fbe 100644 --- a/templates/splitpayments/index.html +++ b/templates/splitpayments/index.html @@ -2,101 +2,493 @@ %} {% block page %}
- - + + + +
Configure Split Payment
+ + + + + + + + +
+
+ + + + +
Select Source Wallet
+

Choose the wallet from which payments will be split.

+ + + + +
+ Split payments successfully activated! +
+
+ {% raw %}{{ lastSavedTargetCount }}{% endraw %} split target{% raw %}{{ lastSavedTargetCount !== 1 ? 's' : '' }}{% endraw %} configured for + {% raw %}{{ selectedWallet ? selectedWallet.name : 'the selected wallet' }}{% endraw %}. +
+ +
+ - + +
+ No wallets available +
+
+ You need at least one wallet to configure split payments. +
+
+ +
+ + + +
+
+
{% raw %}{{ wallet.name }}{% endraw %}
+
{% raw %}{{ wallet.id }}{% endraw %}
+
+
+
+ + {% raw %}{{ walletSplitSummaries[wallet.id].splitCount }}{% endraw %} split{% raw %}{{ walletSplitSummaries[wallet.id].splitCount !== 1 ? 's' : '' }}{% endraw %} + ({% raw %}{{ walletSplitSummaries[wallet.id].totalPercent }}%{% endraw %}) + +
+
+
+
+
+
+ + - + +
Select a source wallet to continue
+
+ +
+ + {% raw %}{{ canProceedFromStep1 ? 'Continue' : 'Select Wallet to Continue' }}{% endraw %} + + +
- - -
-
Target Wallets
+ + + +
Add Targets
+

Specify the recipients of the split payments and the percentage of the payment that each recipient will receive.

+ + +
+
+ + Split payments total: {% raw %}{{ totalPercent }}%{% endraw %} + +
+ {% raw %}{{ remainingPercent }}%{% endraw %} of each payment will remain in the source wallet. +
+
- + + + +
High percentage warning
+
+ Splits totaling close to 100% may fail for some recipients due to Lightning routing fees. Consider reducing the total to 95% or less if you will be sending small payments. +
+
+ + + +
Percentage limit exceeded
+
+ Total percentage is {% raw %}{{ totalPercent }}%{% endraw %} which exceeds 100%. Please reduce the split percentages. +
+
+ + + +
Incomplete configuration
+
+ Please complete all required fields (marked with *) before proceeding. +
+
+ + + +
Duplicate values found
+
+
• Each recipient address must be unique
+
• Each split name must be unique
+
+
+ +
- - - - - - - -
-
-
- - Add Target - -
-
- - Delete all Targets - -
- -
+
+
+ {% raw %}{{ t + 1 }}{% endraw %} +
+ Split Recipient {% raw %}{{ t + 1 }}{% endraw %} of {% raw %}{{ targets.length }}{% endraw %} - Save Targets + Remove this recipient
+ +
+ + + + + +
+
+ +
+ + Add a Split Payment Recipient + Add Another Split Payment Recipient +
+ + +
+
Split Preview
+ +
+ +
+ + + + {% raw %}{{ canProceedFromStep2 ? 'Continue' : (targets.length === 0 ? 'Add Targets to Continue' : allTargetsValid ? (totalPercent > 100 ? 'Fix Percentages to Continue' : 'Continue') : 'Complete Fields to Continue') }}{% endraw %} + +
+ + + + + + +
Review & Confirm
+

Review your split payment configuration and activate when ready.

+ + +
+ + + +
+ + Source Wallet +
+
+
+ {% raw %}{{ selectedWallet ? selectedWallet.name : 'None selected' }}{% endraw %} +
+
+ Keeps {% raw %}{{ remainingPercent }}%{% endraw %} of payments +
+
+
+
+ + + + +
+ + Split Targets +
+
+
+ {% raw %}{{ targets.length }}{% endraw %} recipient{% raw %}{{ targets.length !== 1 ? 's' : '' }}{% endraw %} +
+
+ Total {% raw %}{{ totalPercent }}%{% endraw %} of payments split +
+
+
+
+
+ + +
+
Split Payment Summary
+
+
+
+ {% raw %}{{ target.percent }}%{% endraw %} +
+
+
+ {% raw %}{{ target.alias }}{% endraw %} +
+
+ {% raw %}{{ target.wallet }}{% endraw %} +
+
+
+
+
+ + +
+
Payment Flow Preview
+ +
+ + + + +
Configuration Issues:
+
    +
  • {% raw %}{{ error }}{% endraw %}
  • +
+
+ + + + +
Important:
+
+

• Splits totaling close to 100% may fail due to Lightning routing fees. Please keep this in mind when setting up splits for a wallet where small payments are common. +
+ • Each payment to this wallet will be automatically split when it arrives

+
+
+ + +
+ + + + + {% raw %}{{ validationSummary.length === 0 ? 'Confirm and Activate' : 'Fix Issues to Activate' }}{% endraw %} + +
+
@@ -105,38 +497,85 @@
- - -

- Add some targets to the list of "Target Wallets", each with an - associated percentage. After saving, every time any payment - arrives at the "Source Wallet" that payment will be split with the - target wallets according to their percentage. -

-

- This is valid for every payment, doesn't matter how it was created. -

-

- Targets can be LNBits wallets from this LNBits instance or any valid - LNURL or LN Address. -

-

- LNURLp and LN Addresses must allow comments > 100 chars and also - have a flexible amount. -

-

- To remove a wallet from the targets list just press the X and save. - To remove all, click "Delete all Targets". -

-

- For each split via LNURLp or Lightning addresses a fee_reserve is - substracted, because of potential routing fees. -

-
-
+ +
How splits work
+

+ Add targets to split payments automatically. Every time a payment + arrives at the "Source Wallet", it will be split with the + target wallets according to their percentage. +

+

+ This works for every payment, regardless of how it was created. +

+ +
Supported formats
+ + + + + + + LNbits wallet ID + + + + + + + + LNURLp string + + + + + + + + Lightning address + + + + +
Requirements
+ + + + + + + LNURLp must allow comments > 100 chars + + + + + + + + Receiving wallet must accept flexible amounts + + + + +
Notes on fees
+

+ For each split sent to a Lightning address or LNURLp, a "Fee Reserve" is + subtracted to cover potential routing fees. This fee is not deducted from the + recipient's wallet, but from the source wallet. +

+

+ If the split total is close to 100%, the payment may fail for some recipients because of routing fees. Keep this in mind when setting up splits for a wallet where small payments are common. +

+
-{% endblock %} {% block scripts %} {{ window_vars(user) }} - +{% endblock %} + +{% block styles %} + {% endblock %} + +{% block scripts %} {{ window_vars(user) }} + + +{% endblock %} \ No newline at end of file