@magnet-cms/plugin-polar 0.1.0

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.
@@ -0,0 +1,175 @@
1
+ import { PageHeader, useAdapter } from '@magnet-cms/admin'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ DataTable,
6
+ type DataTableColumn,
7
+ type DataTableRenderContext,
8
+ Input,
9
+ Skeleton,
10
+ } from '@magnet-cms/ui/components'
11
+ import { Search } from 'lucide-react'
12
+ import { useCallback, useEffect, useState } from 'react'
13
+
14
+ interface Benefit {
15
+ id: string
16
+ polarBenefitId: string
17
+ type: string
18
+ description?: string
19
+ organizationId?: string
20
+ selectable: boolean
21
+ createdAt: string
22
+ }
23
+
24
+ const BenefitsPage = () => {
25
+ const adapter = useAdapter()
26
+ const [benefits, setBenefits] = useState<Benefit[]>([])
27
+ const [search, setSearch] = useState('')
28
+ const [loading, setLoading] = useState(true)
29
+
30
+ const fetchBenefits = useCallback(async () => {
31
+ try {
32
+ const data = await adapter.request<Benefit[]>('/polar/admin/benefits')
33
+ setBenefits(data)
34
+ } catch (error) {
35
+ console.error('[Polar] Failed to fetch benefits:', error)
36
+ } finally {
37
+ setLoading(false)
38
+ }
39
+ }, [adapter])
40
+
41
+ useEffect(() => {
42
+ fetchBenefits()
43
+ }, [fetchBenefits])
44
+
45
+ const filtered = benefits.filter(
46
+ (b) =>
47
+ b.type.toLowerCase().includes(search.toLowerCase()) ||
48
+ (b.description ?? '').toLowerCase().includes(search.toLowerCase()),
49
+ )
50
+
51
+ const columns: DataTableColumn<Benefit>[] = [
52
+ {
53
+ type: 'custom',
54
+ header: 'Type',
55
+ cell: (row) => <Badge variant="outline">{row.original.type}</Badge>,
56
+ },
57
+ {
58
+ type: 'text',
59
+ header: 'Description',
60
+ accessorKey: 'description',
61
+ format: (value) => (
62
+ <div className="text-sm text-muted-foreground max-w-[400px] truncate">
63
+ {(value as string) ?? '—'}
64
+ </div>
65
+ ),
66
+ },
67
+ {
68
+ type: 'custom',
69
+ header: 'Selectable',
70
+ cell: (row) => (
71
+ <Badge variant={row.original.selectable ? 'default' : 'secondary'}>
72
+ {row.original.selectable ? 'Yes' : 'No'}
73
+ </Badge>
74
+ ),
75
+ },
76
+ {
77
+ type: 'text',
78
+ header: 'Created',
79
+ accessorKey: 'createdAt',
80
+ format: (value) => new Date(value as string).toLocaleDateString(),
81
+ },
82
+ ]
83
+
84
+ const renderToolbar = () => (
85
+ <div className="px-6 py-4 flex flex-col sm:flex-row gap-3 items-center justify-between flex-none bg-white border-b border-gray-200">
86
+ <div className="relative w-full sm:w-80">
87
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
88
+ <Input
89
+ placeholder="Search benefits..."
90
+ value={search}
91
+ onChange={(e) => setSearch(e.target.value)}
92
+ className="pl-9"
93
+ />
94
+ </div>
95
+ <Button variant="ghost" size="sm" onClick={() => setSearch('')}>
96
+ Clear Filters
97
+ </Button>
98
+ </div>
99
+ )
100
+
101
+ const renderPagination = (table: DataTableRenderContext<Benefit>) => {
102
+ const { pageIndex, pageSize } = table.getState().pagination
103
+ const totalRows = table.getFilteredRowModel().rows.length
104
+ const startRow = pageIndex * pageSize + 1
105
+ const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
106
+ return (
107
+ <div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
108
+ <div className="text-xs text-gray-500">
109
+ Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
110
+ to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
111
+ <span className="font-medium text-gray-900">{totalRows}</span> results
112
+ </div>
113
+ <div className="flex items-center gap-2">
114
+ <Button
115
+ variant="outline"
116
+ size="sm"
117
+ disabled={!table.getCanPreviousPage()}
118
+ onClick={() => table.previousPage()}
119
+ >
120
+ Previous
121
+ </Button>
122
+ <Button
123
+ variant="outline"
124
+ size="sm"
125
+ disabled={!table.getCanNextPage()}
126
+ onClick={() => table.nextPage()}
127
+ >
128
+ Next
129
+ </Button>
130
+ </div>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ if (loading) {
136
+ return (
137
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
138
+ <PageHeader title="Benefits" />
139
+ <div className="flex-1 p-6">
140
+ <Skeleton className="h-96 w-full" />
141
+ </div>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ return (
147
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
148
+ <PageHeader
149
+ title="Benefits"
150
+ description={`${benefits.length} benefit(s) total.`}
151
+ />
152
+ <div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
153
+ <div className="flex-1 overflow-hidden relative">
154
+ <div className="absolute inset-0 overflow-auto">
155
+ <DataTable
156
+ data={filtered}
157
+ columns={columns}
158
+ getRowId={(row) => row.id}
159
+ renderToolbar={renderToolbar}
160
+ renderPagination={renderPagination}
161
+ enablePagination
162
+ pageSizeOptions={[10, 20, 50]}
163
+ initialPagination={{ pageIndex: 0, pageSize: 10 }}
164
+ showCount={false}
165
+ className="h-full flex flex-col"
166
+ variant="content-manager"
167
+ />
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ )
173
+ }
174
+
175
+ export default BenefitsPage
@@ -0,0 +1,187 @@
1
+ import { PageHeader, useAdapter } from '@magnet-cms/admin'
2
+ import {
3
+ Button,
4
+ DataTable,
5
+ type DataTableColumn,
6
+ type DataTableRenderContext,
7
+ Input,
8
+ Skeleton,
9
+ } from '@magnet-cms/ui/components'
10
+ import { ExternalLink, Search } from 'lucide-react'
11
+ import { useCallback, useEffect, useState } from 'react'
12
+
13
+ interface Customer {
14
+ id: string
15
+ polarCustomerId: string
16
+ email: string
17
+ name?: string
18
+ userId?: string
19
+ createdAt: string
20
+ }
21
+
22
+ const CustomersPage = () => {
23
+ const adapter = useAdapter()
24
+ const [customers, setCustomers] = useState<Customer[]>([])
25
+ const [search, setSearch] = useState('')
26
+ const [loading, setLoading] = useState(true)
27
+
28
+ const fetchCustomers = useCallback(async () => {
29
+ try {
30
+ const data = await adapter.request<Customer[]>('/polar/admin/customers')
31
+ setCustomers(data)
32
+ } catch (error) {
33
+ console.error('[Polar] Failed to fetch customers:', error)
34
+ } finally {
35
+ setLoading(false)
36
+ }
37
+ }, [adapter])
38
+
39
+ useEffect(() => {
40
+ fetchCustomers()
41
+ }, [fetchCustomers])
42
+
43
+ const filtered = customers.filter(
44
+ (c) =>
45
+ c.email.toLowerCase().includes(search.toLowerCase()) ||
46
+ (c.name ?? '').toLowerCase().includes(search.toLowerCase()),
47
+ )
48
+
49
+ const columns: DataTableColumn<Customer>[] = [
50
+ {
51
+ type: 'text',
52
+ header: 'Name',
53
+ accessorKey: 'name',
54
+ format: (value) => (
55
+ <div className="text-sm font-medium text-gray-900">
56
+ {(value as string) ?? '—'}
57
+ </div>
58
+ ),
59
+ },
60
+ {
61
+ type: 'text',
62
+ header: 'Email',
63
+ accessorKey: 'email',
64
+ },
65
+ {
66
+ type: 'text',
67
+ header: 'User ID',
68
+ accessorKey: 'userId',
69
+ format: (value) => (
70
+ <span className="font-mono text-sm">{(value as string) ?? '—'}</span>
71
+ ),
72
+ },
73
+ {
74
+ type: 'text',
75
+ header: 'Created',
76
+ accessorKey: 'createdAt',
77
+ format: (value) => new Date(value as string).toLocaleDateString(),
78
+ },
79
+ {
80
+ type: 'custom',
81
+ header: 'Polar',
82
+ cell: (row) => (
83
+ <a
84
+ href={`https://dashboard.polar.sh/customers/${row.original.polarCustomerId}`}
85
+ target="_blank"
86
+ rel="noopener noreferrer"
87
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
88
+ >
89
+ <ExternalLink className="h-3 w-3" />
90
+ View
91
+ </a>
92
+ ),
93
+ },
94
+ ]
95
+
96
+ const renderToolbar = () => (
97
+ <div className="px-6 py-4 flex flex-col sm:flex-row gap-3 items-center justify-between flex-none bg-white border-b border-gray-200">
98
+ <div className="relative w-full sm:w-80">
99
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
100
+ <Input
101
+ placeholder="Search customers..."
102
+ value={search}
103
+ onChange={(e) => setSearch(e.target.value)}
104
+ className="pl-9"
105
+ />
106
+ </div>
107
+ <Button variant="ghost" size="sm" onClick={() => setSearch('')}>
108
+ Clear Filters
109
+ </Button>
110
+ </div>
111
+ )
112
+
113
+ const renderPagination = (table: DataTableRenderContext<Customer>) => {
114
+ const { pageIndex, pageSize } = table.getState().pagination
115
+ const totalRows = table.getFilteredRowModel().rows.length
116
+ const startRow = pageIndex * pageSize + 1
117
+ const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
118
+ return (
119
+ <div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
120
+ <div className="text-xs text-gray-500">
121
+ Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
122
+ to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
123
+ <span className="font-medium text-gray-900">{totalRows}</span> results
124
+ </div>
125
+ <div className="flex items-center gap-2">
126
+ <Button
127
+ variant="outline"
128
+ size="sm"
129
+ disabled={!table.getCanPreviousPage()}
130
+ onClick={() => table.previousPage()}
131
+ >
132
+ Previous
133
+ </Button>
134
+ <Button
135
+ variant="outline"
136
+ size="sm"
137
+ disabled={!table.getCanNextPage()}
138
+ onClick={() => table.nextPage()}
139
+ >
140
+ Next
141
+ </Button>
142
+ </div>
143
+ </div>
144
+ )
145
+ }
146
+
147
+ if (loading) {
148
+ return (
149
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
150
+ <PageHeader title="Customers" />
151
+ <div className="flex-1 p-6">
152
+ <Skeleton className="h-96 w-full" />
153
+ </div>
154
+ </div>
155
+ )
156
+ }
157
+
158
+ return (
159
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
160
+ <PageHeader
161
+ title="Customers"
162
+ description={`${customers.length} customer(s) total.`}
163
+ />
164
+ <div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
165
+ <div className="flex-1 overflow-hidden relative">
166
+ <div className="absolute inset-0 overflow-auto">
167
+ <DataTable
168
+ data={filtered}
169
+ columns={columns}
170
+ getRowId={(row) => row.id}
171
+ renderToolbar={renderToolbar}
172
+ renderPagination={renderPagination}
173
+ enablePagination
174
+ pageSizeOptions={[10, 20, 50]}
175
+ initialPagination={{ pageIndex: 0, pageSize: 10 }}
176
+ showCount={false}
177
+ className="h-full flex flex-col"
178
+ variant="content-manager"
179
+ />
180
+ </div>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ )
185
+ }
186
+
187
+ export default CustomersPage
@@ -0,0 +1,188 @@
1
+ import { PageHeader, useAdapter } from '@magnet-cms/admin'
2
+ import {
3
+ Badge,
4
+ Button,
5
+ DataTable,
6
+ type DataTableColumn,
7
+ type DataTableRenderContext,
8
+ Skeleton,
9
+ } from '@magnet-cms/ui/components'
10
+ import { useEffect, useState } from 'react'
11
+
12
+ interface Order {
13
+ id: string
14
+ polarOrderId: string
15
+ customerId: string
16
+ productId: string
17
+ status: string
18
+ totalAmount: number
19
+ currency: string
20
+ billingReason?: string
21
+ createdAt: string
22
+ }
23
+
24
+ function getStatusVariant(
25
+ status: string,
26
+ ): 'default' | 'secondary' | 'destructive' {
27
+ switch (status) {
28
+ case 'paid':
29
+ return 'default'
30
+ case 'refunded':
31
+ return 'destructive'
32
+ default:
33
+ return 'secondary'
34
+ }
35
+ }
36
+
37
+ function formatCurrency(cents: number, currency: string): string {
38
+ return `${currency.toUpperCase()} $${(cents / 100).toFixed(2)}`
39
+ }
40
+
41
+ const OrdersPage = () => {
42
+ const adapter = useAdapter()
43
+ const [orders, setOrders] = useState<Order[]>([])
44
+ const [loading, setLoading] = useState(true)
45
+
46
+ useEffect(() => {
47
+ async function fetch() {
48
+ try {
49
+ const data = await adapter.request<Order[]>('/polar/admin/orders')
50
+ setOrders(data)
51
+ } catch (error) {
52
+ console.error('[Polar] Failed to fetch orders:', error)
53
+ } finally {
54
+ setLoading(false)
55
+ }
56
+ }
57
+ fetch()
58
+ }, [adapter])
59
+
60
+ const columns: DataTableColumn<Order>[] = [
61
+ {
62
+ type: 'text',
63
+ header: 'Order ID',
64
+ accessorKey: 'polarOrderId',
65
+ format: (value) => (
66
+ <span className="font-mono text-sm">
67
+ {(value as string).slice(0, 12)}...
68
+ </span>
69
+ ),
70
+ },
71
+ {
72
+ type: 'custom',
73
+ header: 'Amount',
74
+ cell: (row) => (
75
+ <span className="font-medium">
76
+ {formatCurrency(row.original.totalAmount, row.original.currency)}
77
+ </span>
78
+ ),
79
+ },
80
+ {
81
+ type: 'custom',
82
+ header: 'Status',
83
+ cell: (row) => (
84
+ <Badge variant={getStatusVariant(row.original.status)}>
85
+ {row.original.status}
86
+ </Badge>
87
+ ),
88
+ },
89
+ {
90
+ type: 'text',
91
+ header: 'Customer',
92
+ accessorKey: 'customerId',
93
+ format: (value) => (
94
+ <span className="font-mono text-sm">{value as string}</span>
95
+ ),
96
+ },
97
+ {
98
+ type: 'text',
99
+ header: 'Reason',
100
+ accessorKey: 'billingReason',
101
+ format: (value) => (
102
+ <span className="text-sm text-muted-foreground">
103
+ {(value as string) ?? '—'}
104
+ </span>
105
+ ),
106
+ },
107
+ {
108
+ type: 'text',
109
+ header: 'Date',
110
+ accessorKey: 'createdAt',
111
+ format: (value) => new Date(value as string).toLocaleDateString(),
112
+ },
113
+ ]
114
+
115
+ const renderPagination = (table: DataTableRenderContext<Order>) => {
116
+ const { pageIndex, pageSize } = table.getState().pagination
117
+ const totalRows = table.getFilteredRowModel().rows.length
118
+ const startRow = pageIndex * pageSize + 1
119
+ const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
120
+ return (
121
+ <div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
122
+ <div className="text-xs text-gray-500">
123
+ Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
124
+ to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
125
+ <span className="font-medium text-gray-900">{totalRows}</span> results
126
+ </div>
127
+ <div className="flex items-center gap-2">
128
+ <Button
129
+ variant="outline"
130
+ size="sm"
131
+ disabled={!table.getCanPreviousPage()}
132
+ onClick={() => table.previousPage()}
133
+ >
134
+ Previous
135
+ </Button>
136
+ <Button
137
+ variant="outline"
138
+ size="sm"
139
+ disabled={!table.getCanNextPage()}
140
+ onClick={() => table.nextPage()}
141
+ >
142
+ Next
143
+ </Button>
144
+ </div>
145
+ </div>
146
+ )
147
+ }
148
+
149
+ if (loading) {
150
+ return (
151
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
152
+ <PageHeader title="Orders" />
153
+ <div className="flex-1 p-6">
154
+ <Skeleton className="h-96 w-full" />
155
+ </div>
156
+ </div>
157
+ )
158
+ }
159
+
160
+ return (
161
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
162
+ <PageHeader
163
+ title="Orders"
164
+ description={`${orders.length} order(s) total.`}
165
+ />
166
+ <div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
167
+ <div className="flex-1 overflow-hidden relative">
168
+ <div className="absolute inset-0 overflow-auto">
169
+ <DataTable
170
+ data={orders}
171
+ columns={columns}
172
+ getRowId={(row) => row.id}
173
+ renderPagination={renderPagination}
174
+ enablePagination
175
+ pageSizeOptions={[10, 20, 50]}
176
+ initialPagination={{ pageIndex: 0, pageSize: 10 }}
177
+ showCount={false}
178
+ className="h-full flex flex-col"
179
+ variant="content-manager"
180
+ />
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </div>
185
+ )
186
+ }
187
+
188
+ export default OrdersPage
@@ -0,0 +1,90 @@
1
+ import { PageContent, PageHeader, useAdapter } from '@magnet-cms/admin'
2
+ import { Card, Skeleton } from '@magnet-cms/ui/components'
3
+ import { useEffect, useState } from 'react'
4
+ import { RecentOrders } from '../components/recent-orders'
5
+ import { RevenueChart } from '../components/revenue-chart'
6
+ import { SubscriptionMetrics } from '../components/subscription-metrics'
7
+
8
+ interface MetricsData {
9
+ mrr: number
10
+ revenueThisMonth: number
11
+ activeSubscriptions: number
12
+ churnRate: number
13
+ revenueByMonth: Array<{ month: string; revenue: number }>
14
+ recentOrders: Array<{
15
+ id: string
16
+ totalAmount: number
17
+ currency: string
18
+ status: string
19
+ customerEmail: string
20
+ createdAt: string
21
+ }>
22
+ }
23
+
24
+ function DashboardSkeleton() {
25
+ return (
26
+ <div className="space-y-6 p-6">
27
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
28
+ {['mrr', 'revenue', 'subscriptions', 'churn'].map((id) => (
29
+ <Skeleton key={id} className="h-24" />
30
+ ))}
31
+ </div>
32
+ <Skeleton className="h-[300px]" />
33
+ <Skeleton className="h-[200px]" />
34
+ </div>
35
+ )
36
+ }
37
+
38
+ const PolarDashboard = () => {
39
+ const adapter = useAdapter()
40
+ const [metrics, setMetrics] = useState<MetricsData | null>(null)
41
+ const [loading, setLoading] = useState(true)
42
+
43
+ useEffect(() => {
44
+ async function fetchMetrics() {
45
+ try {
46
+ const data = await adapter.request<MetricsData>('/polar/admin/metrics')
47
+ setMetrics(data)
48
+ } catch (error) {
49
+ console.error('[Polar] Failed to fetch metrics:', error)
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ }
54
+ fetchMetrics()
55
+ }, [adapter])
56
+
57
+ return (
58
+ <>
59
+ <PageHeader title="Polar Dashboard" />
60
+ <PageContent>
61
+ {loading || !metrics ? (
62
+ <DashboardSkeleton />
63
+ ) : (
64
+ <div className="space-y-6 p-6">
65
+ <SubscriptionMetrics
66
+ mrr={metrics.mrr}
67
+ revenueThisMonth={metrics.revenueThisMonth}
68
+ activeSubscriptions={metrics.activeSubscriptions}
69
+ churnRate={metrics.churnRate}
70
+ />
71
+
72
+ <Card className="p-6">
73
+ <h3 className="text-lg font-semibold mb-4">
74
+ Revenue (Last 12 Months)
75
+ </h3>
76
+ <RevenueChart data={metrics.revenueByMonth} />
77
+ </Card>
78
+
79
+ <div>
80
+ <h3 className="text-lg font-semibold mb-4">Recent Orders</h3>
81
+ <RecentOrders orders={metrics.recentOrders} />
82
+ </div>
83
+ </div>
84
+ )}
85
+ </PageContent>
86
+ </>
87
+ )
88
+ }
89
+
90
+ export default PolarDashboard