init
This commit is contained in:
425
donations/desktop/donations.js
Normal file
425
donations/desktop/donations.js
Normal file
@@ -0,0 +1,425 @@
|
||||
import donationServer from "/donations/@server/donations.js"
|
||||
|
||||
css(`
|
||||
donations- {
|
||||
font-family: 'Arial';
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
donations- input::placeholder {
|
||||
color: var(--headertext);
|
||||
opacity: 0.35;
|
||||
}
|
||||
`)
|
||||
|
||||
class Donations extends Shadow {
|
||||
donations = []
|
||||
subscriptions = []
|
||||
typeFilter = "all"
|
||||
searchText = ""
|
||||
sortKey = "created"
|
||||
sortDir = "desc"
|
||||
|
||||
get allTransactions() {
|
||||
const d = this.donations.map(don => ({
|
||||
id: `d-${don.id}`,
|
||||
created: don.created,
|
||||
name: don.name || "—",
|
||||
email: don.email || "—",
|
||||
amount: Number(don.amount),
|
||||
type: "donation",
|
||||
product: "Donation",
|
||||
status: "complete"
|
||||
}))
|
||||
const s = this.subscriptions.map(sub => ({
|
||||
id: `s-${sub.id}`,
|
||||
created: sub.created,
|
||||
name: [sub.first_name, sub.last_name].filter(Boolean).join(" ") || sub.email || "—",
|
||||
email: sub.email || "—",
|
||||
amount: Number(sub.plan_price),
|
||||
type: "subscription",
|
||||
product: sub.plan_name || "Subscription",
|
||||
status: sub.active ? "active" : "inactive"
|
||||
}))
|
||||
return [...d, ...s]
|
||||
}
|
||||
|
||||
get transactions() {
|
||||
let all = this.allTransactions
|
||||
|
||||
if (this.typeFilter === "donations") all = all.filter(t => t.type === "donation")
|
||||
else if (this.typeFilter === "subscriptions") all = all.filter(t => t.type === "subscription")
|
||||
|
||||
if (this.searchText) {
|
||||
const q = this.searchText.toLowerCase()
|
||||
all = all.filter(t =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.email.toLowerCase().includes(q) ||
|
||||
t.product.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
all.sort((a, b) => {
|
||||
let av = a[this.sortKey], bv = b[this.sortKey]
|
||||
if (this.sortKey === "created") { av = new Date(av); bv = new Date(bv) }
|
||||
else if (this.sortKey === "amount") { av = Number(av); bv = Number(bv) }
|
||||
else { av = String(av).toLowerCase(); bv = String(bv).toLowerCase() }
|
||||
if (av < bv) return this.sortDir === "asc" ? -1 : 1
|
||||
if (av > bv) return this.sortDir === "asc" ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
|
||||
return all
|
||||
}
|
||||
|
||||
get stats() {
|
||||
const all = this.allTransactions
|
||||
const totalRevenue = all.reduce((s, t) => s + t.amount, 0)
|
||||
const now = new Date()
|
||||
const thisMonth = all.filter(t => {
|
||||
const d = new Date(t.created)
|
||||
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear()
|
||||
})
|
||||
const monthRevenue = thisMonth.reduce((s, t) => s + t.amount, 0)
|
||||
const activeSubs = this.subscriptions.filter(s => s.active).length
|
||||
const mrr = this.subscriptions.filter(s => s.active).reduce((s, sub) => s + Number(sub.plan_price), 0)
|
||||
const donorCount = new Set(this.donations.filter(d => d.email).map(d => d.email)).size
|
||||
return { totalRevenue, monthRevenue, activeSubs, mrr, donorCount }
|
||||
}
|
||||
|
||||
get monthlyData() {
|
||||
const now = new Date()
|
||||
const months = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push({ label: d.toLocaleString("en-US", { month: "short" }), year: d.getFullYear(), month: d.getMonth(), donations: 0, subscriptions: 0 })
|
||||
}
|
||||
this.donations.forEach(t => {
|
||||
const d = new Date(t.created)
|
||||
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
|
||||
if (m) m.donations += Number(t.amount)
|
||||
})
|
||||
this.subscriptions.forEach(t => {
|
||||
const d = new Date(t.created)
|
||||
const m = months.find(m => m.year === d.getFullYear() && m.month === d.getMonth())
|
||||
if (m) m.subscriptions += Number(t.plan_price)
|
||||
})
|
||||
return months
|
||||
}
|
||||
|
||||
render() {
|
||||
VStack(() => {
|
||||
this.renderToolbar()
|
||||
|
||||
HStack(() => {
|
||||
this.renderStats()
|
||||
this.renderChart()
|
||||
})
|
||||
.paddingHorizontal(1.25, em)
|
||||
.paddingBottom(1, em)
|
||||
.gap(1, em)
|
||||
.alignItems("stretch")
|
||||
.flexShrink(0)
|
||||
|
||||
VStack(() => {}).height(1, px).background("var(--divider)").marginHorizontal(1.25, em).flexShrink(0)
|
||||
|
||||
this.renderTable()
|
||||
})
|
||||
.height(100, pct)
|
||||
.width(100, pct)
|
||||
.boxSizing("border-box")
|
||||
.overflow("hidden")
|
||||
.onAppear(async () => {
|
||||
const data = await donationServer.getMoneyData(global.currentNetwork.id)
|
||||
if(this.donations.length !== data.purchases.length || this.subscriptions.length !== data.subscriptions.length) {
|
||||
this.donations = data.purchases || []
|
||||
this.subscriptions = data.subscriptions || []
|
||||
console.log(this.donations, this.subscriptions)
|
||||
this.rerender()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
renderToolbar() {
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
this.filterTab("All", "all")
|
||||
this.filterTab("Donations", "donations")
|
||||
this.filterTab("Subscriptions", "subscriptions")
|
||||
})
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.5, em)
|
||||
.padding(0.2, em)
|
||||
.gap(0.15, em)
|
||||
.flexShrink(0)
|
||||
|
||||
input("", "180px")
|
||||
.paddingHorizontal(0.65, em)
|
||||
.paddingVertical(0.42, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.45, em)
|
||||
.fontSize(0.85, em)
|
||||
.color("var(--headertext)")
|
||||
.outline("none")
|
||||
.onInput(v => { this.searchText = v; this.rerender() })
|
||||
})
|
||||
.marginTop(20, px)
|
||||
.paddingHorizontal(1.25, em)
|
||||
.paddingTop(1.1, em)
|
||||
.paddingBottom(0.85, em)
|
||||
.alignItems("center")
|
||||
.gap(0.75, em)
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
filterTab(label, value) {
|
||||
const isActive = this.typeFilter === value
|
||||
p(label)
|
||||
.margin(0)
|
||||
.fontSize(0.8, em)
|
||||
.fontWeight(isActive ? "600" : "400")
|
||||
.color("var(--headertext)")
|
||||
.opacity(isActive ? 1 : 0.5)
|
||||
.paddingHorizontal(0.7, em)
|
||||
.paddingVertical(0.32, em)
|
||||
.borderRadius(0.35, em)
|
||||
.background(isActive ? "var(--app)" : "transparent")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => { if(!done) return; this.typeFilter = value; this.rerender() })
|
||||
}
|
||||
|
||||
renderStats() {
|
||||
const s = this.stats
|
||||
HStack(() => {
|
||||
this.statCard("Total Revenue", this.fmt(s.totalRevenue), "📊")
|
||||
this.statCard("This Month", this.fmt(s.monthRevenue), "📅")
|
||||
this.statCard("Active Subs", String(s.activeSubs), "🔄")
|
||||
this.statCard("MRR", this.fmt(s.mrr), "💰")
|
||||
this.statCard("Donors", String(s.donorCount), "👥")
|
||||
})
|
||||
.gap(0.65, em)
|
||||
.flexShrink(0)
|
||||
.alignSelf("stretch")
|
||||
}
|
||||
|
||||
statCard(label, value, icon) {
|
||||
VStack(() => {
|
||||
p(icon).margin(0).fontSize(1.05, em).lineHeight("1")
|
||||
p(value)
|
||||
.margin(0).marginTop(0.5, em)
|
||||
.fontSize(1.2, em).fontWeight("700")
|
||||
.color("var(--headertext)").lineHeight("1")
|
||||
p(label)
|
||||
.margin(0).marginTop(0.28, em)
|
||||
.fontSize(0.68, em)
|
||||
.color("var(--headertext)").opacity(0.42)
|
||||
})
|
||||
.padding(0.9, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.6, em)
|
||||
.minWidth(0)
|
||||
.flex(1)
|
||||
}
|
||||
|
||||
renderChart() {
|
||||
const months = this.monthlyData
|
||||
const max = Math.max(...months.map(m => m.donations + m.subscriptions), 1)
|
||||
|
||||
VStack(() => {
|
||||
p("Monthly Revenue")
|
||||
.margin(0).marginBottom(0.6, em)
|
||||
.fontSize(0.68, em).fontWeight("700").letterSpacing("0.06em")
|
||||
.color("var(--headertext)").opacity(0.4)
|
||||
|
||||
HStack(() => {
|
||||
months.forEach(m => {
|
||||
const total = m.donations + m.subscriptions
|
||||
const barPct = Math.max((total / max) * 100, total > 0 ? 3 : 0)
|
||||
|
||||
VStack(() => {
|
||||
VStack(() => {
|
||||
if (m.subscriptions > 0) {
|
||||
VStack(() => {}).flex(m.subscriptions).background("#3b82f6")
|
||||
}
|
||||
if (m.donations > 0) {
|
||||
VStack(() => {}).flex(m.donations).background("var(--quillred)")
|
||||
}
|
||||
if (total === 0) {
|
||||
VStack(() => {}).flex(1).background("var(--divider)")
|
||||
}
|
||||
})
|
||||
.height(barPct, pct)
|
||||
.width(60, pct)
|
||||
.borderRadius(3, px)
|
||||
.overflow("hidden")
|
||||
.alignSelf("center")
|
||||
.minHeight(total === 0 ? 2 : barPct < 3 ? 3 : barPct, px)
|
||||
|
||||
p(m.label)
|
||||
.margin(0).marginTop(0.4, em)
|
||||
.fontSize(0.65, em)
|
||||
.color("var(--headertext)").opacity(0.4)
|
||||
.textAlign("center")
|
||||
})
|
||||
.flex(1)
|
||||
.height(100, pct)
|
||||
.alignItems("stretch")
|
||||
.justifyContent("flex-end")
|
||||
.boxSizing("border-box")
|
||||
})
|
||||
})
|
||||
.flex(1)
|
||||
.alignItems("flex-end")
|
||||
.gap(0.3, em)
|
||||
|
||||
HStack(() => {
|
||||
HStack(() => {
|
||||
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("var(--quillred)").flexShrink(0)
|
||||
p("Donations").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
|
||||
}).gap(0.3, em).alignItems("center")
|
||||
|
||||
HStack(() => {
|
||||
VStack(() => {}).width(8, px).height(8, px).borderRadius(50, pct).background("#3b82f6").flexShrink(0)
|
||||
p("Subscriptions").margin(0).fontSize(0.65, em).color("var(--headertext)").opacity(0.45)
|
||||
}).gap(0.3, em).alignItems("center")
|
||||
})
|
||||
.gap(0.85, em)
|
||||
.marginTop(0.55, em)
|
||||
.flexShrink(0)
|
||||
})
|
||||
.padding(1, em)
|
||||
.background("var(--darkaccent)")
|
||||
.border("1px solid var(--divider)")
|
||||
.borderRadius(0.6, em)
|
||||
.flex(1)
|
||||
.minWidth(0)
|
||||
.height(160, px)
|
||||
.boxSizing("border-box")
|
||||
.flexShrink(0)
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const txs = this.transactions
|
||||
const COLS = [
|
||||
{ label: "Date", key: "created", width: "140px" },
|
||||
{ label: "Name", key: "name", width: "1fr" },
|
||||
{ label: "Email", key: "email", width: "220px" },
|
||||
{ label: "Type", key: "type", width: "130px" },
|
||||
{ label: "Product", key: "product", width: "160px" },
|
||||
{ label: "Amount", key: "amount", width: "110px" },
|
||||
{ label: "Status", key: "status", width: "100px" },
|
||||
]
|
||||
const gridCols = COLS.map(c => c.width).join(" ")
|
||||
|
||||
VStack(() => {
|
||||
// Header row
|
||||
HStack(() => {
|
||||
COLS.forEach(col => {
|
||||
const isSort = this.sortKey === col.key
|
||||
HStack(() => {
|
||||
p(col.label)
|
||||
.margin(0).fontSize(0.7, em).fontWeight("700").letterSpacing("0.05em")
|
||||
.color("var(--headertext)").opacity(isSort ? 0.9 : 0.38)
|
||||
if (isSort) {
|
||||
p(this.sortDir === "asc" ? "↑" : "↓")
|
||||
.margin(0).marginLeft(0.2, em).fontSize(0.65, em)
|
||||
.color("var(--headertext)").opacity(0.5)
|
||||
}
|
||||
})
|
||||
.alignItems("center")
|
||||
.cursor("pointer")
|
||||
.onClick((done) => {
|
||||
if(!done) return
|
||||
if (this.sortKey === col.key) this.sortDir = this.sortDir === "asc" ? "desc" : "asc"
|
||||
else { this.sortKey = col.key; this.sortDir = "desc" }
|
||||
this.rerender()
|
||||
})
|
||||
})
|
||||
})
|
||||
.attr({ style: `display: grid; grid-template-columns: ${gridCols};` })
|
||||
.paddingHorizontal(1.25, em)
|
||||
.paddingVertical(0.65, em)
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
.flexShrink(0)
|
||||
|
||||
// Body
|
||||
VStack(() => {
|
||||
if (txs.length === 0) {
|
||||
VStack(() => {
|
||||
p("No transactions match your filters")
|
||||
.margin(0).fontSize(0.9, em)
|
||||
.color("var(--headertext)").opacity(0.4)
|
||||
.textAlign("center")
|
||||
})
|
||||
.flex(1).justifyContent("center").alignItems("center")
|
||||
} else {
|
||||
txs.forEach((tx, i) => {
|
||||
HStack(() => {
|
||||
p(this.formatDate(tx.created))
|
||||
.margin(0).fontSize(0.78, em)
|
||||
.color("var(--headertext)").opacity(0.65)
|
||||
|
||||
p(tx.name)
|
||||
.margin(0).fontSize(0.82, em).color("var(--headertext)")
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
.minWidth(0)
|
||||
|
||||
p(tx.email)
|
||||
.margin(0).fontSize(0.75, em)
|
||||
.color("var(--headertext)").opacity(0.5)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
.minWidth(0)
|
||||
|
||||
HStack(() => {
|
||||
p(tx.type === "donation" ? "Donation" : "Subscription")
|
||||
.margin(0).fontSize(0.7, em).fontWeight("600")
|
||||
.color(tx.type === "donation" ? "var(--quillred)" : "#3b82f6")
|
||||
.background(tx.type === "donation" ? "rgba(159,28,41,0.12)" : "rgba(59,130,246,0.12)")
|
||||
.paddingHorizontal(0.55, em).paddingVertical(0.18, em)
|
||||
.borderRadius(100, px).whiteSpace("nowrap")
|
||||
})
|
||||
|
||||
p(tx.product)
|
||||
.margin(0).fontSize(0.78, em)
|
||||
.color("var(--headertext)").opacity(0.65)
|
||||
.overflow("hidden").whiteSpace("nowrap").textOverflow("ellipsis")
|
||||
.minWidth(0)
|
||||
|
||||
p(this.fmt(tx.amount))
|
||||
.margin(0).fontSize(0.85, em).fontWeight("600")
|
||||
.color("var(--headertext)")
|
||||
|
||||
p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive")
|
||||
.margin(0).fontSize(0.72, em).fontWeight("500")
|
||||
.color(tx.status === "inactive" ? "var(--headertext)" : "#10b981")
|
||||
.opacity(tx.status === "inactive" ? 0.35 : 1)
|
||||
})
|
||||
.attr({ style: `display: grid; grid-template-columns: ${gridCols}; align-items: center;` })
|
||||
.paddingHorizontal(1.25, em)
|
||||
.paddingVertical(0.65, em)
|
||||
.background(i % 2 === 1 ? "var(--darkaccent)" : "transparent")
|
||||
.borderBottom("1px solid var(--divider)")
|
||||
})
|
||||
}
|
||||
})
|
||||
.flex(1)
|
||||
.overflowY("auto")
|
||||
})
|
||||
.flex(1)
|
||||
.minHeight(0)
|
||||
.overflow("hidden")
|
||||
}
|
||||
|
||||
formatDate(time) {
|
||||
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(new Date(time))
|
||||
}
|
||||
|
||||
fmt(amount) {
|
||||
return "$" + Number(amount).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
register(Donations)
|
||||
Reference in New Issue
Block a user