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)