@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.
- package/dist/backend/index.cjs +2494 -0
- package/dist/backend/index.d.cts +435 -0
- package/dist/backend/index.d.ts +435 -0
- package/dist/backend/index.js +22 -0
- package/dist/chunk-7KT7JQIH.js +2474 -0
- package/dist/frontend/bundle.iife.js +23323 -0
- package/dist/frontend/bundle.iife.js.map +1 -0
- package/dist/index.cjs +2474 -0
- package/dist/index.d.cts +140 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +1 -0
- package/package.json +83 -0
- package/src/admin/components/recent-orders.tsx +80 -0
- package/src/admin/components/revenue-chart.tsx +56 -0
- package/src/admin/components/subscription-metrics.tsx +65 -0
- package/src/admin/index.ts +128 -0
- package/src/admin/pages/benefits.tsx +175 -0
- package/src/admin/pages/customers.tsx +187 -0
- package/src/admin/pages/orders.tsx +188 -0
- package/src/admin/pages/polar-dashboard.tsx +90 -0
- package/src/admin/pages/products.tsx +203 -0
- package/src/admin/pages/subscriptions.tsx +245 -0
|
@@ -0,0 +1,203 @@
|
|
|
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 { RefreshCw, Search } from 'lucide-react'
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
13
|
+
|
|
14
|
+
interface Product {
|
|
15
|
+
id: string
|
|
16
|
+
polarProductId: string
|
|
17
|
+
name: string
|
|
18
|
+
description?: string
|
|
19
|
+
isRecurring: boolean
|
|
20
|
+
isArchived: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ProductsPage = () => {
|
|
24
|
+
const adapter = useAdapter()
|
|
25
|
+
const [products, setProducts] = useState<Product[]>([])
|
|
26
|
+
const [search, setSearch] = useState('')
|
|
27
|
+
const [loading, setLoading] = useState(true)
|
|
28
|
+
const [syncing, setSyncing] = useState(false)
|
|
29
|
+
|
|
30
|
+
const fetchProducts = useCallback(async () => {
|
|
31
|
+
try {
|
|
32
|
+
const data = await adapter.request<Product[]>('/polar/admin/products')
|
|
33
|
+
setProducts(data)
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('[Polar] Failed to fetch products:', error)
|
|
36
|
+
} finally {
|
|
37
|
+
setLoading(false)
|
|
38
|
+
}
|
|
39
|
+
}, [adapter])
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
fetchProducts()
|
|
43
|
+
}, [fetchProducts])
|
|
44
|
+
|
|
45
|
+
const handleSync = async () => {
|
|
46
|
+
setSyncing(true)
|
|
47
|
+
try {
|
|
48
|
+
await adapter.request('/polar/admin/sync-products', { method: 'POST' })
|
|
49
|
+
await fetchProducts()
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('[Polar] Failed to sync products:', error)
|
|
52
|
+
} finally {
|
|
53
|
+
setSyncing(false)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const filtered = products.filter(
|
|
58
|
+
(p) =>
|
|
59
|
+
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
|
60
|
+
(p.description ?? '').toLowerCase().includes(search.toLowerCase()),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const columns: DataTableColumn<Product>[] = [
|
|
64
|
+
{
|
|
65
|
+
type: 'text',
|
|
66
|
+
header: 'Name',
|
|
67
|
+
accessorKey: 'name',
|
|
68
|
+
format: (value) => (
|
|
69
|
+
<div className="text-sm font-medium text-gray-900">
|
|
70
|
+
{value as string}
|
|
71
|
+
</div>
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'text',
|
|
76
|
+
header: 'Description',
|
|
77
|
+
accessorKey: 'description',
|
|
78
|
+
format: (value) => (
|
|
79
|
+
<div className="text-sm text-muted-foreground max-w-[300px] truncate">
|
|
80
|
+
{(value as string) ?? '—'}
|
|
81
|
+
</div>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
type: 'custom',
|
|
86
|
+
header: 'Type',
|
|
87
|
+
cell: (row) => (
|
|
88
|
+
<Badge variant="outline">
|
|
89
|
+
{row.original.isRecurring ? 'Recurring' : 'One-time'}
|
|
90
|
+
</Badge>
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
type: 'custom',
|
|
95
|
+
header: 'Status',
|
|
96
|
+
cell: (row) => (
|
|
97
|
+
<Badge variant={row.original.isArchived ? 'secondary' : 'default'}>
|
|
98
|
+
{row.original.isArchived ? 'Archived' : 'Active'}
|
|
99
|
+
</Badge>
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
const renderToolbar = () => (
|
|
105
|
+
<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">
|
|
106
|
+
<div className="relative w-full sm:w-80">
|
|
107
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
108
|
+
<Input
|
|
109
|
+
placeholder="Search products..."
|
|
110
|
+
value={search}
|
|
111
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
112
|
+
className="pl-9"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
<Button variant="ghost" size="sm" onClick={() => setSearch('')}>
|
|
116
|
+
Clear Filters
|
|
117
|
+
</Button>
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const renderPagination = (table: DataTableRenderContext<Product>) => {
|
|
122
|
+
const { pageIndex, pageSize } = table.getState().pagination
|
|
123
|
+
const totalRows = table.getFilteredRowModel().rows.length
|
|
124
|
+
const startRow = pageIndex * pageSize + 1
|
|
125
|
+
const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
|
|
128
|
+
<div className="text-xs text-gray-500">
|
|
129
|
+
Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
|
|
130
|
+
to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
|
|
131
|
+
<span className="font-medium text-gray-900">{totalRows}</span> results
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex items-center gap-2">
|
|
134
|
+
<Button
|
|
135
|
+
variant="outline"
|
|
136
|
+
size="sm"
|
|
137
|
+
disabled={!table.getCanPreviousPage()}
|
|
138
|
+
onClick={() => table.previousPage()}
|
|
139
|
+
>
|
|
140
|
+
Previous
|
|
141
|
+
</Button>
|
|
142
|
+
<Button
|
|
143
|
+
variant="outline"
|
|
144
|
+
size="sm"
|
|
145
|
+
disabled={!table.getCanNextPage()}
|
|
146
|
+
onClick={() => table.nextPage()}
|
|
147
|
+
>
|
|
148
|
+
Next
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (loading) {
|
|
156
|
+
return (
|
|
157
|
+
<div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
|
|
158
|
+
<PageHeader title="Products" />
|
|
159
|
+
<div className="flex-1 p-6">
|
|
160
|
+
<Skeleton className="h-96 w-full" />
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
|
|
168
|
+
<PageHeader
|
|
169
|
+
title="Products"
|
|
170
|
+
description={`${products.length} product(s). Click "Sync from Polar" to import.`}
|
|
171
|
+
actions={
|
|
172
|
+
<Button onClick={handleSync} disabled={syncing} variant="outline">
|
|
173
|
+
<RefreshCw
|
|
174
|
+
className={`h-4 w-4 mr-2 ${syncing ? 'animate-spin' : ''}`}
|
|
175
|
+
/>
|
|
176
|
+
Sync from Polar
|
|
177
|
+
</Button>
|
|
178
|
+
}
|
|
179
|
+
/>
|
|
180
|
+
<div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
|
181
|
+
<div className="flex-1 overflow-hidden relative">
|
|
182
|
+
<div className="absolute inset-0 overflow-auto">
|
|
183
|
+
<DataTable
|
|
184
|
+
data={filtered}
|
|
185
|
+
columns={columns}
|
|
186
|
+
getRowId={(row) => row.id}
|
|
187
|
+
renderToolbar={renderToolbar}
|
|
188
|
+
renderPagination={renderPagination}
|
|
189
|
+
enablePagination
|
|
190
|
+
pageSizeOptions={[10, 20, 50]}
|
|
191
|
+
initialPagination={{ pageIndex: 0, pageSize: 10 }}
|
|
192
|
+
showCount={false}
|
|
193
|
+
className="h-full flex flex-col"
|
|
194
|
+
variant="content-manager"
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export default ProductsPage
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { PageHeader, useAdapter } from '@magnet-cms/admin'
|
|
2
|
+
import {
|
|
3
|
+
Badge,
|
|
4
|
+
Button,
|
|
5
|
+
DataTable,
|
|
6
|
+
type DataTableColumn,
|
|
7
|
+
type DataTableRenderContext,
|
|
8
|
+
Select,
|
|
9
|
+
SelectContent,
|
|
10
|
+
SelectItem,
|
|
11
|
+
SelectTrigger,
|
|
12
|
+
SelectValue,
|
|
13
|
+
Skeleton,
|
|
14
|
+
} from '@magnet-cms/ui/components'
|
|
15
|
+
import { useEffect, useState } from 'react'
|
|
16
|
+
|
|
17
|
+
interface Subscription {
|
|
18
|
+
id: string
|
|
19
|
+
polarSubscriptionId: string
|
|
20
|
+
customerId: string
|
|
21
|
+
productId: string
|
|
22
|
+
status: string
|
|
23
|
+
amount?: number
|
|
24
|
+
currency?: string
|
|
25
|
+
currentPeriodStart: string
|
|
26
|
+
currentPeriodEnd: string
|
|
27
|
+
cancelAtPeriodEnd: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getStatusVariant(
|
|
31
|
+
status: string,
|
|
32
|
+
): 'default' | 'secondary' | 'destructive' | 'outline' {
|
|
33
|
+
switch (status) {
|
|
34
|
+
case 'active':
|
|
35
|
+
case 'trialing':
|
|
36
|
+
return 'default'
|
|
37
|
+
case 'past_due':
|
|
38
|
+
case 'unpaid':
|
|
39
|
+
return 'destructive'
|
|
40
|
+
case 'canceled':
|
|
41
|
+
case 'revoked':
|
|
42
|
+
return 'secondary'
|
|
43
|
+
default:
|
|
44
|
+
return 'outline'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SubscriptionsPage = () => {
|
|
49
|
+
const adapter = useAdapter()
|
|
50
|
+
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
|
|
51
|
+
const [statusFilter, setStatusFilter] = useState<string>('all')
|
|
52
|
+
const [loading, setLoading] = useState(true)
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
async function fetch() {
|
|
56
|
+
try {
|
|
57
|
+
const data = await adapter.request<Subscription[]>(
|
|
58
|
+
'/polar/admin/subscriptions',
|
|
59
|
+
)
|
|
60
|
+
setSubscriptions(data)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
console.error('[Polar] Failed to fetch subscriptions:', error)
|
|
63
|
+
} finally {
|
|
64
|
+
setLoading(false)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
fetch()
|
|
68
|
+
}, [adapter])
|
|
69
|
+
|
|
70
|
+
const filtered =
|
|
71
|
+
statusFilter === 'all'
|
|
72
|
+
? subscriptions
|
|
73
|
+
: subscriptions.filter((s) => s.status === statusFilter)
|
|
74
|
+
|
|
75
|
+
const handleCancel = async (polarSubscriptionId: string) => {
|
|
76
|
+
try {
|
|
77
|
+
await adapter.request(
|
|
78
|
+
`/polar/admin/subscriptions/${polarSubscriptionId}/cancel`,
|
|
79
|
+
{ method: 'POST' },
|
|
80
|
+
)
|
|
81
|
+
setSubscriptions((prev) =>
|
|
82
|
+
prev.map((s) =>
|
|
83
|
+
s.polarSubscriptionId === polarSubscriptionId
|
|
84
|
+
? { ...s, status: 'revoked' }
|
|
85
|
+
: s,
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.error('[Polar] Failed to cancel subscription:', error)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const columns: DataTableColumn<Subscription>[] = [
|
|
94
|
+
{
|
|
95
|
+
type: 'text',
|
|
96
|
+
header: 'Customer',
|
|
97
|
+
accessorKey: 'customerId',
|
|
98
|
+
format: (value) => (
|
|
99
|
+
<span className="font-mono text-sm">{value as string}</span>
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'text',
|
|
104
|
+
header: 'Product',
|
|
105
|
+
accessorKey: 'productId',
|
|
106
|
+
format: (value) => (
|
|
107
|
+
<span className="font-mono text-sm">{value as string}</span>
|
|
108
|
+
),
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
type: 'custom',
|
|
112
|
+
header: 'Status',
|
|
113
|
+
cell: (row) => (
|
|
114
|
+
<Badge variant={getStatusVariant(row.original.status)}>
|
|
115
|
+
{row.original.status}
|
|
116
|
+
{row.original.cancelAtPeriodEnd && ' (canceling)'}
|
|
117
|
+
</Badge>
|
|
118
|
+
),
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: 'custom',
|
|
122
|
+
header: 'Period',
|
|
123
|
+
cell: (row) => (
|
|
124
|
+
<span className="text-sm text-muted-foreground">
|
|
125
|
+
{new Date(row.original.currentPeriodStart).toLocaleDateString()}
|
|
126
|
+
{' — '}
|
|
127
|
+
{new Date(row.original.currentPeriodEnd).toLocaleDateString()}
|
|
128
|
+
</span>
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
type: 'custom',
|
|
133
|
+
header: 'Actions',
|
|
134
|
+
cell: (row) =>
|
|
135
|
+
row.original.status === 'active' && !row.original.cancelAtPeriodEnd ? (
|
|
136
|
+
<Button
|
|
137
|
+
variant="destructive"
|
|
138
|
+
size="sm"
|
|
139
|
+
onClick={() => handleCancel(row.original.polarSubscriptionId)}
|
|
140
|
+
>
|
|
141
|
+
Revoke
|
|
142
|
+
</Button>
|
|
143
|
+
) : null,
|
|
144
|
+
},
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
const renderToolbar = () => (
|
|
148
|
+
<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">
|
|
149
|
+
<div className="max-w-[200px]">
|
|
150
|
+
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
151
|
+
<SelectTrigger>
|
|
152
|
+
<SelectValue placeholder="Filter by status" />
|
|
153
|
+
</SelectTrigger>
|
|
154
|
+
<SelectContent>
|
|
155
|
+
<SelectItem value="all">All Statuses</SelectItem>
|
|
156
|
+
<SelectItem value="active">Active</SelectItem>
|
|
157
|
+
<SelectItem value="trialing">Trialing</SelectItem>
|
|
158
|
+
<SelectItem value="past_due">Past Due</SelectItem>
|
|
159
|
+
<SelectItem value="canceled">Canceled</SelectItem>
|
|
160
|
+
<SelectItem value="revoked">Revoked</SelectItem>
|
|
161
|
+
<SelectItem value="unpaid">Unpaid</SelectItem>
|
|
162
|
+
</SelectContent>
|
|
163
|
+
</Select>
|
|
164
|
+
</div>
|
|
165
|
+
<Button variant="ghost" size="sm" onClick={() => setStatusFilter('all')}>
|
|
166
|
+
Clear Filters
|
|
167
|
+
</Button>
|
|
168
|
+
</div>
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
const renderPagination = (table: DataTableRenderContext<Subscription>) => {
|
|
172
|
+
const { pageIndex, pageSize } = table.getState().pagination
|
|
173
|
+
const totalRows = table.getFilteredRowModel().rows.length
|
|
174
|
+
const startRow = pageIndex * pageSize + 1
|
|
175
|
+
const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
|
|
176
|
+
return (
|
|
177
|
+
<div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
|
|
178
|
+
<div className="text-xs text-gray-500">
|
|
179
|
+
Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
|
|
180
|
+
to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
|
|
181
|
+
<span className="font-medium text-gray-900">{totalRows}</span> results
|
|
182
|
+
</div>
|
|
183
|
+
<div className="flex items-center gap-2">
|
|
184
|
+
<Button
|
|
185
|
+
variant="outline"
|
|
186
|
+
size="sm"
|
|
187
|
+
disabled={!table.getCanPreviousPage()}
|
|
188
|
+
onClick={() => table.previousPage()}
|
|
189
|
+
>
|
|
190
|
+
Previous
|
|
191
|
+
</Button>
|
|
192
|
+
<Button
|
|
193
|
+
variant="outline"
|
|
194
|
+
size="sm"
|
|
195
|
+
disabled={!table.getCanNextPage()}
|
|
196
|
+
onClick={() => table.nextPage()}
|
|
197
|
+
>
|
|
198
|
+
Next
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (loading) {
|
|
206
|
+
return (
|
|
207
|
+
<div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
|
|
208
|
+
<PageHeader title="Subscriptions" />
|
|
209
|
+
<div className="flex-1 p-6">
|
|
210
|
+
<Skeleton className="h-96 w-full" />
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
|
|
218
|
+
<PageHeader
|
|
219
|
+
title="Subscriptions"
|
|
220
|
+
description={`${subscriptions.length} subscription(s) total.`}
|
|
221
|
+
/>
|
|
222
|
+
<div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
|
|
223
|
+
<div className="flex-1 overflow-hidden relative">
|
|
224
|
+
<div className="absolute inset-0 overflow-auto">
|
|
225
|
+
<DataTable
|
|
226
|
+
data={filtered}
|
|
227
|
+
columns={columns}
|
|
228
|
+
getRowId={(row) => row.id}
|
|
229
|
+
renderToolbar={renderToolbar}
|
|
230
|
+
renderPagination={renderPagination}
|
|
231
|
+
enablePagination
|
|
232
|
+
pageSizeOptions={[10, 20, 50]}
|
|
233
|
+
initialPagination={{ pageIndex: 0, pageSize: 10 }}
|
|
234
|
+
showCount={false}
|
|
235
|
+
className="h-full flex flex-col"
|
|
236
|
+
variant="content-manager"
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default SubscriptionsPage
|