@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,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