import donationServer from "/donations/@server/donations.js" import "/_/code/components/LoadingCircle.js" css(` donations- { font-family: 'Arial'; scrollbar-width: none; -ms-overflow-style: none; } donations-::-webkit-scrollbar { display: none; } donations- input::placeholder { color: var(--headertext); opacity: 0.35; } `) class Donations extends Shadow { donations = [] subscriptions = [] typeFilter = "all" searchText = "" searchOpen = false 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].sort((a, b) => new Date(b.created) - new Date(a.created)) } 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) ) } return all } get stats() { const all = this.allTransactions const totalRevenue = all.reduce((s, t) => s + t.amount, 0) const now = new Date() const monthRevenue = all .filter(t => { const d = new Date(t.created) return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() }) .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 } } render() { VStack(() => { this.renderHeader() this.renderStats() this.renderFilters() this.renderList() }) .height(100, pct) .width(100, pct) .maxWidth(100, vw) .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 || [] this.rerender() } }) } renderHeader() { if (this.searchOpen) { HStack(() => { input(this.searchText, "100%") .paddingHorizontal(0.75, em) .paddingVertical(0.5, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.65, em) .fontSize(0.9, em) .color("var(--headertext)") .outline("none") .attr({ autofocus: "true" }) .onInput(v => { this.searchText = v; this.rerender() }) }) .paddingHorizontal(1.1, em) .paddingBottom(0.65, em) .flexShrink(0) } } renderStats() { const s = this.stats const cards = [ { label: "Total", value: this.fmt(s.totalRevenue), icon: "📊" }, { label: "This Month", value: this.fmt(s.monthRevenue), icon: "📅" }, { label: "MRR", value: this.fmt(s.mrr), icon: "🔄" }, { label: "Donors", value: String(s.donorCount), icon: "👥" }, { label: "Active Subs", value: String(s.activeSubs), icon: "💰" }, ] HStack(() => { cards.forEach(c => { VStack(() => { p(c.icon).margin(0).fontSize(1, em).lineHeight("1") p(c.value) .margin(0).marginTop(0.35, em) .fontSize(1.05, em).fontWeight("700") .color("var(--headertext)").lineHeight("1") p(c.label) .margin(0).marginTop(0.22, em) .fontSize(0.62, em) .color("var(--headertext)").opacity(0.42) }) .padding(0.8, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.75, em) .alignItems("center") .minWidth(80, px) .flexShrink(0) }) }) .paddingHorizontal(1.1, em) .paddingBottom(0.75, em) .gap(0.6, em) .overflowX("auto") .flexShrink(0) } renderFilters() { HStack(() => { this.filterTab("All", "all") this.filterTab("Donations", "donations") this.filterTab("Subscriptions", "subscriptions") }) .paddingHorizontal(1.1, em) .paddingBottom(0.65, em) .gap(0.4, 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.9, em) .paddingVertical(0.45, em) .borderRadius(100, px) .background(isActive ? "var(--darkaccent)" : "transparent") .border(`1px solid ${isActive ? "var(--divider)" : "transparent"}`) .cursor("pointer") .onTouch((start) => { if (!start) { this.typeFilter = value this.rerender() } }) } renderList() { const txs = this.transactions VStack(() => { if (this.donations.length === 0 && this.subscriptions.length === 0) { LoadingCircle() } else if (txs.length === 0) { VStack(() => { p("No transactions match your filters") .margin(0).fontSize(0.9, em) .color("var(--headertext)").opacity(0.38) .textAlign("center") }).flex(1).justifyContent("center").alignItems("center") } else { txs.forEach(tx => this.renderCard(tx)) } }) .flex(1) .minHeight(0) .overflowY("auto") .paddingHorizontal(1.1, em) .paddingBottom(1.5, em) .gap(0.6, em) } renderCard(tx) { VStack(() => { HStack(() => { // Name + type badge VStack(() => { p(tx.name) .margin(0) .fontSize(0.92, em) .fontWeight("600") .color("var(--headertext)") .overflow("hidden") .whiteSpace("nowrap") .textOverflow("ellipsis") .maxWidth(180, px) p(tx.email) .margin(0).marginTop(0.18, em) .fontSize(0.72, em) .color("var(--headertext)").opacity(0.45) .overflow("hidden") .whiteSpace("nowrap") .textOverflow("ellipsis") .maxWidth(180, px) }) .flex(1).minWidth(0).alignItems("flex-start") // Amount + status VStack(() => { p(this.fmt(tx.amount)) .margin(0) .fontSize(1, em).fontWeight("700") .color("var(--headertext)") .textAlign("right") p(tx.status === "active" ? "Active" : tx.status === "complete" ? "Complete" : "Inactive") .margin(0).marginTop(0.2, em) .fontSize(0.68, em).fontWeight("500") .color(tx.status === "inactive" ? "var(--headertext)" : "#10b981") .opacity(tx.status === "inactive" ? 0.35 : 1) .textAlign("right") }) .alignItems("flex-end").flexShrink(0) }) .alignItems("flex-start") HStack(() => { // Type badge p(tx.type === "donation" ? "Donation" : tx.product) .margin(0) .fontSize(0.68, 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.2, em) .borderRadius(100, px) .whiteSpace("nowrap") p(this.formatDate(tx.created)) .margin(0) .fontSize(0.72, em) .color("var(--headertext)").opacity(0.38) .flex(1).textAlign("right") }) .marginTop(0.65, em) .alignItems("center") .gap(0.5, em) }) .padding(0.95, em) .background("var(--darkaccent)") .border("1px solid var(--divider)") .borderRadius(0.75, em) } 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: 2, maximumFractionDigits: 2 }) } } register(Donations)