@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,120 @@
1
+ /**
2
+ * Stripe Plugin - Frontend Entry
3
+ *
4
+ * Self-registers the plugin when loaded via script injection.
5
+ * The admin app loads plugin bundles at runtime and plugins self-register
6
+ * on window.__MAGNET_PLUGINS__.
7
+ */
8
+
9
+ import type { ComponentType } from 'react'
10
+
11
+ interface FrontendPluginManifest {
12
+ pluginName: string
13
+ routes?: {
14
+ path: string
15
+ componentId: string
16
+ children?: { path: string; componentId: string }[]
17
+ }[]
18
+ sidebar?: {
19
+ id: string
20
+ title: string
21
+ url: string
22
+ icon: string
23
+ order?: number
24
+ items?: { id: string; title: string; url: string; icon: string }[]
25
+ }[]
26
+ }
27
+
28
+ interface PluginRegistration {
29
+ manifest: FrontendPluginManifest
30
+ components: Record<string, () => Promise<{ default: ComponentType<unknown> }>>
31
+ }
32
+
33
+ const manifest: FrontendPluginManifest = {
34
+ pluginName: 'stripe',
35
+ routes: [
36
+ {
37
+ path: 'stripe',
38
+ componentId: 'StripeDashboard',
39
+ children: [
40
+ { path: '', componentId: 'StripeDashboard' },
41
+ { path: 'customers', componentId: 'StripeCustomers' },
42
+ { path: 'products', componentId: 'StripeProducts' },
43
+ { path: 'subscriptions', componentId: 'StripeSubscriptions' },
44
+ { path: 'payments', componentId: 'StripePayments' },
45
+ ],
46
+ },
47
+ ],
48
+ sidebar: [
49
+ {
50
+ id: 'stripe',
51
+ title: 'Stripe Payments',
52
+ url: '/stripe',
53
+ icon: 'CreditCard',
54
+ order: 30,
55
+ items: [
56
+ {
57
+ id: 'stripe-dashboard',
58
+ title: 'Dashboard',
59
+ url: '/stripe',
60
+ icon: 'BarChart3',
61
+ },
62
+ {
63
+ id: 'stripe-customers',
64
+ title: 'Customers',
65
+ url: '/stripe/customers',
66
+ icon: 'Users',
67
+ },
68
+ {
69
+ id: 'stripe-products',
70
+ title: 'Products',
71
+ url: '/stripe/products',
72
+ icon: 'Package',
73
+ },
74
+ {
75
+ id: 'stripe-subscriptions',
76
+ title: 'Subscriptions',
77
+ url: '/stripe/subscriptions',
78
+ icon: 'RefreshCw',
79
+ },
80
+ {
81
+ id: 'stripe-payments',
82
+ title: 'Payments',
83
+ url: '/stripe/payments',
84
+ icon: 'Receipt',
85
+ },
86
+ ],
87
+ },
88
+ ],
89
+ }
90
+
91
+ const components: Record<
92
+ string,
93
+ () => Promise<{ default: ComponentType<unknown> }>
94
+ > = {
95
+ StripeDashboard: () => import('./pages/stripe-dashboard'),
96
+ StripeCustomers: () => import('./pages/customers'),
97
+ StripeProducts: () => import('./pages/products'),
98
+ StripeSubscriptions: () => import('./pages/subscriptions'),
99
+ StripePayments: () => import('./pages/payments'),
100
+ }
101
+
102
+ function registerPlugin() {
103
+ if (!window.__MAGNET_PLUGINS__) {
104
+ window.__MAGNET_PLUGINS__ = []
105
+ }
106
+
107
+ const alreadyRegistered = window.__MAGNET_PLUGINS__.some(
108
+ (p) => p.manifest.pluginName === manifest.pluginName,
109
+ )
110
+
111
+ if (!alreadyRegistered) {
112
+ window.__MAGNET_PLUGINS__.push({ manifest, components })
113
+ console.log(`[Magnet] Plugin registered: ${manifest.pluginName}`)
114
+ }
115
+ }
116
+
117
+ registerPlugin()
118
+
119
+ export const stripePlugin = () => ({ manifest, components })
120
+ export default stripePlugin
@@ -0,0 +1,209 @@
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 { useEffect, useState } from 'react'
12
+
13
+ const contentManagerStyles = `
14
+ .table-row-hover:hover td {
15
+ background-color: #F9FAFB;
16
+ }
17
+ .table-row-hover.group:hover td {
18
+ background-color: #F9FAFB;
19
+ }
20
+ `
21
+
22
+ interface Customer {
23
+ id: string
24
+ stripeCustomerId: string
25
+ email: string
26
+ name?: string
27
+ userId?: string
28
+ createdAt: string
29
+ }
30
+
31
+ const CustomersPage = () => {
32
+ const adapter = useAdapter()
33
+ const [customers, setCustomers] = useState<Customer[]>([])
34
+ const [search, setSearch] = useState('')
35
+ const [loading, setLoading] = useState(true)
36
+
37
+ useEffect(() => {
38
+ async function fetch() {
39
+ try {
40
+ const data = await adapter.request<Customer[]>(
41
+ '/stripe/admin/customers',
42
+ )
43
+ setCustomers(data)
44
+ } catch (error) {
45
+ console.error('[Stripe] Failed to fetch customers:', error)
46
+ } finally {
47
+ setLoading(false)
48
+ }
49
+ }
50
+ fetch()
51
+ }, [adapter])
52
+
53
+ const filtered = customers.filter(
54
+ (c) =>
55
+ c.email.toLowerCase().includes(search.toLowerCase()) ||
56
+ (c.name ?? '').toLowerCase().includes(search.toLowerCase()),
57
+ )
58
+
59
+ const columns: DataTableColumn<Customer>[] = [
60
+ {
61
+ type: 'text',
62
+ header: 'Name',
63
+ accessorKey: 'name',
64
+ format: (value, row) => (
65
+ <div className="text-sm font-medium text-gray-900">
66
+ {(value as string) ?? row.original.name ?? '—'}
67
+ </div>
68
+ ),
69
+ },
70
+ {
71
+ type: 'text',
72
+ header: 'Email',
73
+ accessorKey: 'email',
74
+ format: (value) => (
75
+ <div className="text-sm text-gray-600">{value as string}</div>
76
+ ),
77
+ },
78
+ {
79
+ type: 'custom',
80
+ header: 'User ID',
81
+ cell: (row) =>
82
+ row.original.userId ? (
83
+ <span className="inline-flex items-center rounded-md bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 ring-1 ring-inset ring-gray-600/20">
84
+ {row.original.userId}
85
+ </span>
86
+ ) : (
87
+ <span className="text-muted-foreground">—</span>
88
+ ),
89
+ },
90
+ {
91
+ type: 'text',
92
+ header: 'Created',
93
+ accessorKey: 'createdAt',
94
+ format: (value) => (
95
+ <span className="text-sm text-gray-500">
96
+ {new Date(value as string).toLocaleDateString()}
97
+ </span>
98
+ ),
99
+ },
100
+ {
101
+ type: 'custom',
102
+ header: 'Stripe',
103
+ cell: (row) => (
104
+ <a
105
+ href={`https://dashboard.stripe.com/customers/${row.original.stripeCustomerId}`}
106
+ target="_blank"
107
+ rel="noopener noreferrer"
108
+ className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
109
+ >
110
+ <ExternalLink className="h-3 w-3" />
111
+ View
112
+ </a>
113
+ ),
114
+ },
115
+ ]
116
+
117
+ const renderToolbar = () => (
118
+ <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">
119
+ <div className="relative w-full sm:w-80">
120
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
121
+ <Input
122
+ placeholder="Search customers..."
123
+ value={search}
124
+ onChange={(e) => setSearch(e.target.value)}
125
+ className="pl-9"
126
+ />
127
+ </div>
128
+ <Button variant="ghost" size="sm" onClick={() => setSearch('')}>
129
+ Clear Filters
130
+ </Button>
131
+ </div>
132
+ )
133
+
134
+ const renderPagination = (table: DataTableRenderContext<Customer>) => {
135
+ const { pageIndex, pageSize } = table.getState().pagination
136
+ const totalRows = table.getFilteredRowModel().rows.length
137
+ const startRow = pageIndex * pageSize + 1
138
+ const endRow = Math.min((pageIndex + 1) * pageSize, totalRows)
139
+ return (
140
+ <div className="flex-none px-6 py-4 border-t border-gray-200 bg-white flex items-center justify-between">
141
+ <div className="text-xs text-gray-500">
142
+ Showing <span className="font-medium text-gray-900">{startRow}</span>{' '}
143
+ to <span className="font-medium text-gray-900">{endRow}</span> of{' '}
144
+ <span className="font-medium text-gray-900">{totalRows}</span> results
145
+ </div>
146
+ <div className="flex items-center gap-2">
147
+ <Button
148
+ variant="outline"
149
+ size="sm"
150
+ disabled={!table.getCanPreviousPage()}
151
+ onClick={() => table.previousPage()}
152
+ >
153
+ Previous
154
+ </Button>
155
+ <Button
156
+ variant="outline"
157
+ size="sm"
158
+ disabled={!table.getCanNextPage()}
159
+ onClick={() => table.nextPage()}
160
+ >
161
+ Next
162
+ </Button>
163
+ </div>
164
+ </div>
165
+ )
166
+ }
167
+
168
+ if (loading) {
169
+ return (
170
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
171
+ <PageHeader title="Customers" />
172
+ <div className="flex-1 p-6">
173
+ <Skeleton className="h-96 w-full" />
174
+ </div>
175
+ </div>
176
+ )
177
+ }
178
+
179
+ return (
180
+ <div className="flex-1 flex flex-col min-w-0 bg-white h-full relative overflow-hidden">
181
+ <style>{contentManagerStyles}</style>
182
+ <PageHeader
183
+ title="Customers"
184
+ description={`${customers.length} customer(s) total.`}
185
+ />
186
+ <div className="flex-1 flex flex-col overflow-hidden bg-gray-50">
187
+ <div className="flex-1 overflow-hidden relative">
188
+ <div className="absolute inset-0 overflow-auto">
189
+ <DataTable
190
+ data={filtered}
191
+ columns={columns}
192
+ getRowId={(row) => row.id}
193
+ renderToolbar={renderToolbar}
194
+ renderPagination={renderPagination}
195
+ enablePagination
196
+ pageSizeOptions={[10, 20, 50]}
197
+ initialPagination={{ pageIndex: 0, pageSize: 10 }}
198
+ showCount={false}
199
+ className="h-full flex flex-col"
200
+ variant="content-manager"
201
+ />
202
+ </div>
203
+ </div>
204
+ </div>
205
+ </div>
206
+ )
207
+ }
208
+
209
+ export default CustomersPage
@@ -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 Payment {
27
+ id: string
28
+ stripePaymentIntentId: string
29
+ customerId: string
30
+ amount: number
31
+ currency: string
32
+ status: string
33
+ receiptUrl?: string
34
+ invoiceId?: string
35
+ createdAt: string
36
+ }
37
+
38
+ function formatCurrency(cents: number, currency: string): string {
39
+ return `${currency.toUpperCase()} ${(cents / 100).toFixed(2)}`
40
+ }
41
+
42
+ function getStatusVariant(
43
+ status: string,
44
+ ): 'default' | 'secondary' | 'destructive' | 'outline' {
45
+ switch (status) {
46
+ case 'succeeded':
47
+ return 'default'
48
+ case 'failed':
49
+ return 'destructive'
50
+ case 'refunded':
51
+ return 'secondary'
52
+ default:
53
+ return 'outline'
54
+ }
55
+ }
56
+
57
+ const PaymentsPage = () => {
58
+ const adapter = useAdapter()
59
+ const [payments, setPayments] = useState<Payment[]>([])
60
+ const [statusFilter, setStatusFilter] = useState<string>('all')
61
+ const [loading, setLoading] = useState(true)
62
+
63
+ useEffect(() => {
64
+ async function fetch() {
65
+ try {
66
+ const data = await adapter.request<Payment[]>('/stripe/admin/payments')
67
+ setPayments(data)
68
+ } catch (error) {
69
+ console.error('[Stripe] Failed to fetch payments:', error)
70
+ } finally {
71
+ setLoading(false)
72
+ }
73
+ }
74
+ fetch()
75
+ }, [adapter])
76
+
77
+ const filtered =
78
+ statusFilter === 'all'
79
+ ? payments
80
+ : payments.filter((p) => p.status === statusFilter)
81
+
82
+ const handleRefund = async (paymentIntentId: string) => {
83
+ try {
84
+ await adapter.request(
85
+ `/stripe/admin/payments/${paymentIntentId}/refund`,
86
+ { method: 'POST' },
87
+ )
88
+ setPayments((prev) =>
89
+ prev.map((p) =>
90
+ p.stripePaymentIntentId === paymentIntentId
91
+ ? { ...p, status: 'refunded' }
92
+ : p,
93
+ ),
94
+ )
95
+ } catch (error) {
96
+ console.error('[Stripe] Failed to refund payment:', error)
97
+ }
98
+ }
99
+
100
+ const columns: DataTableColumn<Payment>[] = [
101
+ {
102
+ type: 'custom',
103
+ header: 'Amount',
104
+ cell: (row) => (
105
+ <span className="font-medium">
106
+ {formatCurrency(row.original.amount, row.original.currency)}
107
+ </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
+ </Badge>
117
+ ),
118
+ },
119
+ {
120
+ type: 'text',
121
+ header: 'Customer',
122
+ accessorKey: 'customerId',
123
+ format: (value) => (
124
+ <span className="font-mono text-sm text-muted-foreground">
125
+ {value as string}
126
+ </span>
127
+ ),
128
+ },
129
+ {
130
+ type: 'text',
131
+ header: 'Date',
132
+ accessorKey: 'createdAt',
133
+ format: (value) => (
134
+ <span className="text-muted-foreground">
135
+ {new Date(value as string).toLocaleDateString()}
136
+ </span>
137
+ ),
138
+ },
139
+ {
140
+ type: 'custom',
141
+ header: 'Actions',
142
+ cell: (row) =>
143
+ row.original.status === 'succeeded' ? (
144
+ <Button
145
+ variant="outline"
146
+ size="sm"
147
+ onClick={() => handleRefund(row.original.stripePaymentIntentId)}
148
+ >
149
+ Refund
150
+ </Button>
151
+ ) : null,
152
+ },
153
+ ]
154
+
155
+ const renderToolbar = () => (
156
+ <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">
157
+ <div className="max-w-[200px]">
158
+ <Select value={statusFilter} onValueChange={setStatusFilter}>
159
+ <SelectTrigger>
160
+ <SelectValue placeholder="Filter by status" />
161
+ </SelectTrigger>
162
+ <SelectContent>
163
+ <SelectItem value="all">All Statuses</SelectItem>
164
+ <SelectItem value="succeeded">Succeeded</SelectItem>
165
+ <SelectItem value="failed">Failed</SelectItem>
166
+ <SelectItem value="refunded">Refunded</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<Payment>) => {
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="Payments" />
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="Payments"
226
+ description={`${payments.length} payment(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 PaymentsPage