@subvalue/cli 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PRE_RELEASE.md +1 -1
- package/README.md +6 -2
- package/dist/packages/cli/src/server.js +75 -28
- package/dist/packages/core/src/billing.js +112 -0
- package/dist/packages/core/src/summary.js +69 -68
- package/dist/packages/core/src/types.js +11 -2
- package/dist/packages/receipt/src/index.js +165 -17
- package/dist/packages/ui/src/brand.js +1 -0
- package/dist/public/app.css +210 -2
- package/dist/public/apps/web/src/landing.js +20 -5
- package/dist/public/apps/web/src/main.js +268 -69
- package/dist/public/landing.css +265 -29
- package/dist/public/packages/core/src/billing.js +112 -0
- package/dist/public/packages/receipt/src/index.js +165 -17
- package/dist/public/packages/ui/src/brand.js +1 -0
- package/package.json +1 -1
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
|
|
2
2
|
import { product, productMark, productNameMarkup } from '../../../packages/ui/src/brand.js';
|
|
3
3
|
document.title = product.name + ' — Local usage';
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
billingForSelection,
|
|
12
|
+
withPeriodBilling,
|
|
13
|
+
showExpenseAction,
|
|
14
|
+
} from '../../../packages/core/src/billing.js';
|
|
5
15
|
import {
|
|
6
16
|
subscriptionPlans,
|
|
7
17
|
subscriptionPlan,
|
|
@@ -60,6 +70,8 @@ const icon = (key ) => {
|
|
|
60
70
|
<path d="${paths[key] ?? paths.token}" />
|
|
61
71
|
</svg>`;
|
|
62
72
|
};
|
|
73
|
+
let expenseProvider = null;
|
|
74
|
+
let expensesSaving = false;
|
|
63
75
|
let settings ,
|
|
64
76
|
sources = [],
|
|
65
77
|
summary ,
|
|
@@ -521,6 +533,17 @@ function providerCards() {
|
|
|
521
533
|
>
|
|
522
534
|
</div>
|
|
523
535
|
</div>
|
|
536
|
+
${
|
|
537
|
+
showExpenseAction(p, summary.providers)
|
|
538
|
+
? `<button type="button" class="provider-expense-toggle" data-expense-toggle="${p.provider}" aria-controls="card-expenses-${p.provider}" aria-expanded="false"><span>Categorize expenses for this period</span> ${icon('next')}</button>
|
|
539
|
+
<div id="card-expenses-${p.provider}" class="provider-expenses-popover" popover aria-label="${name(p.provider)} expenses">
|
|
540
|
+
<div class="expenses-heading"><h2>${name(p.provider)} · Expenses</h2><button type="button" class="text-button" popovertarget="card-expenses-${p.provider}" popovertargetaction="hide" aria-label="Close expenses">×</button></div>
|
|
541
|
+
<form class="provider-expenses-form" data-period-billing="${p.provider}">
|
|
542
|
+
${billingFields(p.provider, true, billingForSelection(settings, p.provider, summary.range), true, 'card-')}
|
|
543
|
+
<div class="expense-actions"><small>${esc(periodLabel())}</small><button class="button small" type="submit" ${expensesSaving ? 'disabled' : ''}>Save</button></div>
|
|
544
|
+
<p class="billing-error" role="alert" hidden></p></form></div>`
|
|
545
|
+
: ''
|
|
546
|
+
}
|
|
524
547
|
</article>`,
|
|
525
548
|
)
|
|
526
549
|
.join('')}
|
|
@@ -570,23 +593,55 @@ function overview() {
|
|
|
570
593
|
c = comparisonForDisplay(summary);
|
|
571
594
|
const active = summary.providers.filter((p) => p.records);
|
|
572
595
|
const months = fullCalendarMonths(summary.range),
|
|
573
|
-
showSubs = months !== null && active.length &&
|
|
596
|
+
showSubs = months !== null && active.length && (c.paid != null || c.scope.length > 0),
|
|
597
|
+
scopeLabel = c.scope.map(name).join(' + '),
|
|
598
|
+
first = summary.providers
|
|
599
|
+
.map((p) => p.first)
|
|
600
|
+
.filter((value) => !!value)
|
|
601
|
+
.sort()[0],
|
|
602
|
+
last = summary.providers
|
|
603
|
+
.map((p) => p.last)
|
|
604
|
+
.filter((value) => !!value)
|
|
605
|
+
.sort()
|
|
606
|
+
.at(-1),
|
|
607
|
+
historyDate = (value ) =>
|
|
608
|
+
new Date(value).toLocaleDateString('en-US', {
|
|
609
|
+
month: 'short',
|
|
610
|
+
day: 'numeric',
|
|
611
|
+
year: 'numeric',
|
|
612
|
+
timeZone: 'UTC',
|
|
613
|
+
});
|
|
574
614
|
return /* HTML */ `<div class="page-heading">
|
|
575
|
-
<div
|
|
615
|
+
<div class="overview-title">
|
|
616
|
+
<h1>Overview</h1>
|
|
617
|
+
<span class="selected-period" aria-live="polite">${esc(periodLabel())}</span>
|
|
618
|
+
</div>
|
|
576
619
|
${refreshButton()}
|
|
577
620
|
</div>
|
|
578
621
|
${coverage()}
|
|
579
622
|
<div class="overview-layout">
|
|
580
623
|
<div class="overview-main">
|
|
581
624
|
<div class="metrics ${showSubs ? '' : 'single'}">
|
|
582
|
-
${metric('API equivalent', t.apiEquivalent === null ? (t.knownSubtotal !== null ? money(t.knownSubtotal) : t.records ? '—' : 'No data') : money(t.apiEquivalent), periodLabel(), 'api-metric')}${showSubs ? metric('
|
|
625
|
+
${metric('API equivalent', t.apiEquivalent === null ? (t.knownSubtotal !== null ? money(t.knownSubtotal) : t.records ? '—' : 'No data') : money(t.apiEquivalent), periodLabel(), 'api-metric')}${showSubs ? metric(c.apiSpend ? 'You paid' : c.subscription === 0 ? 'API paid' : 'Subscription', money(c.paid ?? c.subscription), [scopeLabel, months === 1 ? 'Full month' : `${months} full months`].filter(Boolean).join(' · ')) : ''}${showSubs ? metric(summary.historyPartial ? 'Observed value' : 'Value', c.value === null ? '—' : money(c.value, true), [scopeLabel, c.value === null ? 'Comparison unavailable' : summary.historyPartial ? 'Partial history' : ''].filter(Boolean).join(' · '), c.value !== null ? (c.value >= 0 ? 'positive' : 'negative') : '') : ''}${showSubs ? metric('Value multiple', c.roi === null ? '—' : `${c.roi.toFixed(2)}<span class="multiply">×</span>`, [scopeLabel, c.breakEven ? `Break-even ${new Date(c.breakEven + 'T12:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}` : c.roi === null ? 'Unavailable' : t.apiEquivalent === null ? 'Priced portion' : ''].filter(Boolean).join(' · ')) : ''}
|
|
583
626
|
</div>
|
|
584
627
|
${providerCards()}${chart()}
|
|
585
628
|
<div class="overview-bottom">
|
|
586
629
|
<span>${icon('shield')}Local analysis. Read-only sources.</span
|
|
587
630
|
><button class="text-button" data-page="settings">Manage sources ${icon('arrow')}</button>
|
|
588
631
|
</div>
|
|
589
|
-
${
|
|
632
|
+
${
|
|
633
|
+
!t.records
|
|
634
|
+
? /* HTML */ `<section class="panel empty-panel">
|
|
635
|
+
<h2>No data for this period</h2>
|
|
636
|
+
${first && last ? `<p class="empty-history">Available history · ${esc(historyDate(first))} – ${esc(historyDate(last))}</p>` : ''}<button
|
|
637
|
+
class="text-button"
|
|
638
|
+
data-page="settings"
|
|
639
|
+
>
|
|
640
|
+
View sources →
|
|
641
|
+
</button>
|
|
642
|
+
</section>`
|
|
643
|
+
: ''
|
|
644
|
+
}
|
|
590
645
|
</div>
|
|
591
646
|
${receiptPanel(true)}
|
|
592
647
|
</div>`;
|
|
@@ -612,40 +667,52 @@ function receiptPage() {
|
|
|
612
667
|
</aside>
|
|
613
668
|
</div>`;
|
|
614
669
|
}
|
|
615
|
-
function billingFields(
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
<
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
670
|
+
function billingFields(
|
|
671
|
+
p ,
|
|
672
|
+
firstRun = false,
|
|
673
|
+
b = settings.billing[p],
|
|
674
|
+
expenses = false,
|
|
675
|
+
prefix = '',
|
|
676
|
+
) {
|
|
677
|
+
const plan = subscriptionPlan(p, b.planId),
|
|
678
|
+
subscribed = b.mode === 'SUBSCRIPTION' || b.mode === 'MIXED',
|
|
679
|
+
selection = subscribed
|
|
680
|
+
? (plan?.id ?? 'custom')
|
|
681
|
+
: b.mode === 'NO_SUBSCRIPTION' || b.mode === 'API'
|
|
682
|
+
? 'none'
|
|
683
|
+
: '';
|
|
684
|
+
const api = !subscribed || b.mode === 'MIXED';
|
|
685
|
+
return /* HTML */ `<div class="billing-row" data-billing-provider="${p}">
|
|
686
|
+
<label class="provider-setting-name" for="${prefix}plan-${p}">${name(p)}</label>
|
|
687
|
+
<div class="subscription-fields">
|
|
688
|
+
<label class="sr-only" for="${prefix}plan-${p}">${name(p)} subscription plan</label
|
|
689
|
+
><select
|
|
690
|
+
id="${prefix}plan-${p}"
|
|
691
|
+
name="plan-${p}"
|
|
692
|
+
data-subscription-plan="${p}"
|
|
693
|
+
${firstRun ? 'required data-first-run="true"' : ''}
|
|
694
|
+
>
|
|
695
|
+
<option value="" ${selection === '' ? 'selected' : ''}>
|
|
696
|
+
${firstRun ? 'Choose subscription' : 'Not set'}
|
|
697
|
+
</option>
|
|
698
|
+
<option value="none" ${selection === 'none' ? 'selected' : ''}>
|
|
699
|
+
No subscription · API
|
|
700
|
+
</option>
|
|
639
701
|
${subscriptionPlans
|
|
640
702
|
.filter((plan) => plan.provider === p)
|
|
641
703
|
.map(
|
|
642
704
|
(option) =>
|
|
643
|
-
/* HTML */ `<option value="${option.id}" ${
|
|
705
|
+
/* HTML */ `<option value="${option.id}" ${selection === option.id ? 'selected' : ''}>
|
|
644
706
|
${esc(subscriptionPlanLabel(option))}
|
|
645
707
|
</option>`,
|
|
646
708
|
)
|
|
647
|
-
.join('')}
|
|
648
|
-
|
|
709
|
+
.join('')}
|
|
710
|
+
<option value="custom" ${selection === 'custom' ? 'selected' : ''}>
|
|
711
|
+
Custom amount
|
|
712
|
+
</option></select
|
|
713
|
+
><label
|
|
714
|
+
class="amount-field billing-amount ${subscribed ? '' : 'hidden'}"
|
|
715
|
+
id="${prefix}amount-${p}"
|
|
649
716
|
><span class="sr-only">${name(p)} monthly subscription in USD</span><span>$</span
|
|
650
717
|
><input
|
|
651
718
|
name="monthly-${p}"
|
|
@@ -655,13 +722,36 @@ function billingFields(p , firstRun = false) {
|
|
|
655
722
|
step="0.01"
|
|
656
723
|
value="${plan ? plan.monthly.toFixed(2) : (b.monthly ?? '')}"
|
|
657
724
|
${plan ? 'readonly' : ''}
|
|
658
|
-
${
|
|
725
|
+
${!subscribed ? 'disabled' : ''}
|
|
726
|
+
${firstRun && subscribed ? 'required' : ''}
|
|
659
727
|
placeholder="Amount"
|
|
660
728
|
/><span>/ month</span></label
|
|
661
729
|
>
|
|
730
|
+
<label class="mixed-field ${subscribed ? '' : 'hidden'}"
|
|
731
|
+
><input
|
|
732
|
+
type="checkbox"
|
|
733
|
+
name="mixed-${p}"
|
|
734
|
+
data-mixed-billing="${p}"
|
|
735
|
+
${b.mode === 'MIXED' ? 'checked' : ''}
|
|
736
|
+
${subscribed ? '' : 'disabled'}
|
|
737
|
+
/>Subscription + API</label
|
|
738
|
+
>
|
|
739
|
+
${expenses ? `<label class="api-spend-field ${api ? '' : 'hidden'}"><span>API paid</span><div class="amount-field"><span>$</span><input name="api-spend-${p}" type="number" min="0" max="1000000" step="0.01" value="${b.apiSpend ?? (b.mode === 'MIXED' ? 0 : '')}" ${api ? '' : 'disabled'} placeholder="Not set" aria-label="${name(p)} API paid in USD"/></div></label>` : ''}
|
|
662
740
|
</div>
|
|
663
741
|
</div>`;
|
|
664
742
|
}
|
|
743
|
+
function expenseDialog() {
|
|
744
|
+
if (!expenseProvider) return '';
|
|
745
|
+
const billing = billingForSelection(settings, expenseProvider, summary.range);
|
|
746
|
+
return `<dialog id="expenses-dialog" aria-labelledby="expenses-title">
|
|
747
|
+
<div class="expenses-heading"><h2 id="expenses-title">${name(expenseProvider)} · Expenses</h2><button type="button" class="text-button" id="close-expenses" aria-label="Close expenses">×</button></div>
|
|
748
|
+
<form id="expenses-form">
|
|
749
|
+
<p class="expense-period">${esc(periodLabel())}</p>
|
|
750
|
+
${billingFields(expenseProvider, true, billing, true, 'period-')}
|
|
751
|
+
<p class="expense-note">This period only · USD</p><p id="expense-error" role="alert" hidden></p>
|
|
752
|
+
<div class="expense-actions"><button type="button" class="text-button" id="reset-expenses">Use default</button><button class="button" type="submit">Save</button></div>
|
|
753
|
+
</form></dialog>`;
|
|
754
|
+
}
|
|
665
755
|
function settingsPage() {
|
|
666
756
|
return /* HTML */ `<div class="page-heading">
|
|
667
757
|
<div><h1>Settings</h1></div>
|
|
@@ -734,7 +824,11 @@ function settingsPage() {
|
|
|
734
824
|
<h2>Billing</h2>
|
|
735
825
|
${(['codex', 'claude'] )
|
|
736
826
|
.filter((p) => sources.find((s) => s.provider === p)?.detected)
|
|
737
|
-
.map(
|
|
827
|
+
.map(
|
|
828
|
+
(p) =>
|
|
829
|
+
billingFields(p) +
|
|
830
|
+
`<button type="button" class="text-button settings-expenses" data-expenses="${p}">${name(p)} · Monthly expenses</button>`,
|
|
831
|
+
)
|
|
738
832
|
.join('')}
|
|
739
833
|
<details class="compact-details">
|
|
740
834
|
<summary>Details</summary>
|
|
@@ -754,8 +848,8 @@ function settingsPage() {
|
|
|
754
848
|
</p>
|
|
755
849
|
<p>
|
|
756
850
|
These are your declarations, not inferred billing history. Amounts are in USD.
|
|
757
|
-
Comparisons use
|
|
758
|
-
|
|
851
|
+
Comparisons use full calendar months. API spend belongs to the selected month; missing
|
|
852
|
+
amounts stay unknown. Value multiple is API equivalent divided by total paid.
|
|
759
853
|
</p>
|
|
760
854
|
</details>
|
|
761
855
|
</section>
|
|
@@ -792,7 +886,7 @@ function onboarding() {
|
|
|
792
886
|
<span class="brand-mark">${productMark}</span
|
|
793
887
|
><span class="eyebrow">${esc(product.name.toUpperCase())} / FIRST RUN</span>
|
|
794
888
|
<h2 id="onboarding-title">
|
|
795
|
-
${onboardingStage === 'found' ? (found.length ? 'Local usage found' : 'No local usage found') : '
|
|
889
|
+
${onboardingStage === 'found' ? (found.length ? 'Local usage found' : 'No local usage found') : 'Your subscriptions'}
|
|
796
890
|
</h2>
|
|
797
891
|
${
|
|
798
892
|
onboardingStage === 'found'
|
|
@@ -804,7 +898,7 @@ function onboarding() {
|
|
|
804
898
|
${billable
|
|
805
899
|
.map(
|
|
806
900
|
(p) =>
|
|
807
|
-
/* HTML */ `<h3
|
|
901
|
+
/* HTML */ `<h3>${name(p.provider)}</h3>
|
|
808
902
|
${billingFields(p.provider, true)}`,
|
|
809
903
|
)
|
|
810
904
|
.join('')}<button class="button" type="submit">
|
|
@@ -830,6 +924,14 @@ function render() {
|
|
|
830
924
|
</footer>
|
|
831
925
|
</div>`
|
|
832
926
|
}`;
|
|
927
|
+
app.insertAdjacentHTML('beforeend', expenseDialog());
|
|
928
|
+
const expense = document.querySelector ('#expenses-dialog');
|
|
929
|
+
if (expense) {
|
|
930
|
+
expense.addEventListener('cancel', () => {
|
|
931
|
+
expenseProvider = null;
|
|
932
|
+
});
|
|
933
|
+
expense.showModal();
|
|
934
|
+
}
|
|
833
935
|
const canvas = document.querySelector ('#receipt-canvas');
|
|
834
936
|
if (canvas) drawReceipt(canvas, displayedReceipt, settings.receiptTheme ?? 'dark');
|
|
835
937
|
const dialog = document.querySelector ('#onboarding');
|
|
@@ -839,22 +941,36 @@ function render() {
|
|
|
839
941
|
}
|
|
840
942
|
bind();
|
|
841
943
|
}
|
|
842
|
-
function
|
|
944
|
+
function billingFromForm(form , p ) {
|
|
843
945
|
const data = new FormData(form);
|
|
946
|
+
const selection = data.get(`plan-${p}`);
|
|
947
|
+
if (selection === null) return null;
|
|
948
|
+
const subscribed = selection !== 'none' && selection !== '',
|
|
949
|
+
mode = subscribed ? (data.has(`mixed-${p}`) ? 'MIXED' : 'SUBSCRIPTION') : 'API',
|
|
950
|
+
plan = subscriptionPlan(p, String(selection)),
|
|
951
|
+
amount = data.get(`monthly-${p}`),
|
|
952
|
+
api = data.get(`api-spend-${p}`);
|
|
953
|
+
return {
|
|
954
|
+
mode,
|
|
955
|
+
monthly: subscribed
|
|
956
|
+
? (plan?.monthly ?? (amount !== '' && amount !== null ? Number(amount) : null))
|
|
957
|
+
: null,
|
|
958
|
+
...(subscribed ? { planId: plan?.id ?? null } : {}),
|
|
959
|
+
...(mode !== 'SUBSCRIPTION'
|
|
960
|
+
? { apiSpend: api !== '' && api !== null ? Number(api) : null }
|
|
961
|
+
: {}),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function readBilling(form ) {
|
|
844
965
|
for (const p of ['codex', 'claude'] ) {
|
|
845
|
-
const
|
|
846
|
-
if (
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
? (plan?.monthly ?? (amount !== '' && amount !== null ? Number(amount) : null))
|
|
854
|
-
: null,
|
|
855
|
-
...(mode === 'SUBSCRIPTION' ? { planId: plan?.id ?? null } : {}),
|
|
856
|
-
};
|
|
857
|
-
}
|
|
966
|
+
const billing = billingFromForm(form, p);
|
|
967
|
+
if (!billing) continue;
|
|
968
|
+
// API payments are entered in the provider card for a specific period.
|
|
969
|
+
settings.billing[p] = {
|
|
970
|
+
mode: billing.mode,
|
|
971
|
+
monthly: billing.monthly,
|
|
972
|
+
...(billing.planId !== undefined ? { planId: billing.planId } : {}),
|
|
973
|
+
};
|
|
858
974
|
}
|
|
859
975
|
}
|
|
860
976
|
async function save() {
|
|
@@ -1011,27 +1127,110 @@ function bind() {
|
|
|
1011
1127
|
const data = new FormData(e.currentTarget );
|
|
1012
1128
|
void choosePeriod('month', from, to, String(data.get('month')));
|
|
1013
1129
|
});
|
|
1014
|
-
|
|
1015
|
-
select.
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1130
|
+
const updateBillingFields = (row ) => {
|
|
1131
|
+
const select = row.querySelector ('[data-subscription-plan]') ;
|
|
1132
|
+
const p = select.dataset.subscriptionPlan ,
|
|
1133
|
+
plan = subscriptionPlan(p, select.value),
|
|
1134
|
+
input = row.querySelector (`[name="monthly-${p}"]`) ,
|
|
1135
|
+
mixed = row.querySelector ('[data-mixed-billing]') ;
|
|
1136
|
+
const subscribed = select.value !== '' && select.value !== 'none';
|
|
1137
|
+
row.querySelector('.billing-amount')?.classList.toggle('hidden', !subscribed);
|
|
1138
|
+
row.querySelector('.mixed-field')?.classList.toggle('hidden', !subscribed);
|
|
1139
|
+
input.disabled = mixed.disabled = !subscribed;
|
|
1140
|
+
input.required = select.dataset.firstRun === 'true' && subscribed;
|
|
1141
|
+
input.readOnly = !!plan;
|
|
1142
|
+
if (plan) input.value = plan.monthly.toFixed(2);
|
|
1143
|
+
const api = !subscribed || mixed.checked;
|
|
1144
|
+
row.querySelector('.api-spend-field')?.classList.toggle('hidden', !api);
|
|
1145
|
+
const apiInput = row.querySelector (`[name="api-spend-${p}"]`);
|
|
1146
|
+
if (apiInput) {
|
|
1147
|
+
apiInput.disabled = !api;
|
|
1148
|
+
if (mixed.checked && subscribed && apiInput.value === '') apiInput.value = '0';
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
document.querySelectorAll ('[data-billing-provider]').forEach((row) => {
|
|
1152
|
+
row
|
|
1153
|
+
.querySelectorAll('select, [data-mixed-billing]')
|
|
1154
|
+
.forEach((field) => field.addEventListener('change', () => updateBillingFields(row)));
|
|
1155
|
+
});
|
|
1156
|
+
document.querySelectorAll ('[data-expenses]').forEach((button) =>
|
|
1157
|
+
button.addEventListener('click', () => {
|
|
1158
|
+
expenseProvider = button.dataset.expenses ;
|
|
1159
|
+
render();
|
|
1024
1160
|
}),
|
|
1025
1161
|
);
|
|
1026
|
-
document.querySelectorAll ('[data-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
if (
|
|
1162
|
+
document.querySelectorAll ('[data-expense-toggle]').forEach((button) => {
|
|
1163
|
+
const panel = document.getElementById(button.getAttribute('aria-controls') ) ;
|
|
1164
|
+
panel.addEventListener('toggle', () =>
|
|
1165
|
+
button.setAttribute('aria-expanded', String(panel.matches(':popover-open'))),
|
|
1166
|
+
);
|
|
1167
|
+
button.addEventListener('click', () => {
|
|
1168
|
+
if (panel.matches(':popover-open')) {
|
|
1169
|
+
panel.hidePopover();
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
panel.showPopover();
|
|
1173
|
+
const bounds = button.getBoundingClientRect();
|
|
1174
|
+
panel.style.left =
|
|
1175
|
+
Math.max(
|
|
1176
|
+
16,
|
|
1177
|
+
Math.min(innerWidth - panel.offsetWidth - 16, bounds.right - panel.offsetWidth),
|
|
1178
|
+
) + 'px';
|
|
1179
|
+
panel.style.top =
|
|
1180
|
+
Math.max(16, Math.min(innerHeight - panel.offsetHeight - 16, bounds.bottom + 8)) + 'px';
|
|
1181
|
+
panel.querySelector ('select')?.focus();
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
document.querySelector('#close-expenses')?.addEventListener('click', () => {
|
|
1185
|
+
expenseProvider = null;
|
|
1186
|
+
render();
|
|
1187
|
+
});
|
|
1188
|
+
const persistExpenses = async (provider , form , reset = false) => {
|
|
1189
|
+
if (expensesSaving) return;
|
|
1190
|
+
expensesSaving = true;
|
|
1191
|
+
const billing = billingFromForm(form, provider),
|
|
1192
|
+
previous = settings,
|
|
1193
|
+
next = withPeriodBilling(settings, provider, summary.range, reset ? null : billing);
|
|
1194
|
+
document
|
|
1195
|
+
.querySelectorAll ('[data-period-billing] button, #expenses-form button')
|
|
1196
|
+
.forEach((button) => (button.disabled = true));
|
|
1197
|
+
try {
|
|
1198
|
+
await api('/api/settings', 'POST', next);
|
|
1199
|
+
settings = next;
|
|
1200
|
+
await load();
|
|
1201
|
+
expenseProvider = null;
|
|
1202
|
+
expensesSaving = false;
|
|
1203
|
+
render();
|
|
1204
|
+
toast('Saved locally');
|
|
1205
|
+
} catch {
|
|
1206
|
+
settings = previous;
|
|
1207
|
+
expensesSaving = false;
|
|
1208
|
+
const error = form.querySelector ('.billing-error, #expense-error') ;
|
|
1209
|
+
error.hidden = false;
|
|
1210
|
+
error.textContent = 'Unable to save expenses';
|
|
1211
|
+
document
|
|
1212
|
+
.querySelectorAll ('[data-period-billing] button, #expenses-form button')
|
|
1213
|
+
.forEach((button) => (button.disabled = false));
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
document.querySelectorAll ('[data-period-billing]').forEach((form) =>
|
|
1217
|
+
form.addEventListener('submit', (e) => {
|
|
1218
|
+
e.preventDefault();
|
|
1219
|
+
void persistExpenses(form.dataset.periodBilling , form);
|
|
1033
1220
|
}),
|
|
1034
1221
|
);
|
|
1222
|
+
document.querySelector ('#expenses-form')?.addEventListener('submit', (e) => {
|
|
1223
|
+
e.preventDefault();
|
|
1224
|
+
if (expenseProvider) void persistExpenses(expenseProvider, e.currentTarget );
|
|
1225
|
+
});
|
|
1226
|
+
document.querySelector('#reset-expenses')?.addEventListener('click', () => {
|
|
1227
|
+
if (expenseProvider)
|
|
1228
|
+
void persistExpenses(
|
|
1229
|
+
expenseProvider,
|
|
1230
|
+
document.querySelector ('#expenses-form') ,
|
|
1231
|
+
true,
|
|
1232
|
+
);
|
|
1233
|
+
});
|
|
1035
1234
|
document
|
|
1036
1235
|
.querySelector ('#settings-form')
|
|
1037
1236
|
?.addEventListener('submit', async (e) => {
|