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