@mars-stack/cli 3.0.1 → 4.0.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,276 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { Card, Badge, Button, LinkButton } from '@mars-stack/ui';
5
+ import { appConfig } from '@/config/app.config';
6
+ import { routes } from '@/config/routes';
7
+ import { useAuth } from '@/features/auth/context/AuthContext';
8
+
9
+ interface PlanTier {
10
+ name: string;
11
+ description: string;
12
+ monthlyPrice: number;
13
+ annualPrice: number;
14
+ priceId?: string;
15
+ annualPriceId?: string;
16
+ features: string[];
17
+ highlighted?: boolean;
18
+ cta: string;
19
+ }
20
+
21
+ const PLACEHOLDER_PLANS: PlanTier[] = [
22
+ {
23
+ name: 'Starter',
24
+ description: 'For individuals getting started.',
25
+ monthlyPrice: 0,
26
+ annualPrice: 0,
27
+ features: [
28
+ 'Up to 1,000 requests/month',
29
+ 'Basic analytics',
30
+ 'Community support',
31
+ 'Single user',
32
+ ],
33
+ cta: 'Get started free',
34
+ },
35
+ {
36
+ name: 'Pro',
37
+ description: 'For growing teams and businesses.',
38
+ monthlyPrice: 29,
39
+ annualPrice: 290,
40
+ features: [
41
+ 'Unlimited requests',
42
+ 'Advanced analytics',
43
+ 'Priority support',
44
+ 'Up to 10 team members',
45
+ 'Custom integrations',
46
+ 'API access',
47
+ ],
48
+ highlighted: true,
49
+ cta: 'Start free trial',
50
+ },
51
+ {
52
+ name: 'Enterprise',
53
+ description: 'For large-scale operations.',
54
+ monthlyPrice: 99,
55
+ annualPrice: 990,
56
+ features: [
57
+ 'Everything in Pro',
58
+ 'Unlimited team members',
59
+ 'SSO & SAML',
60
+ 'Dedicated support',
61
+ 'Custom SLA',
62
+ 'Audit logs',
63
+ 'On-premise option',
64
+ ],
65
+ cta: 'Contact sales',
66
+ },
67
+ ];
68
+
69
+ function formatPrice(amount: number): string {
70
+ if (amount === 0) return 'Free';
71
+ return `$${amount}`;
72
+ }
73
+
74
+ function CheckIcon() {
75
+ return (
76
+ <svg className="h-4 w-4 shrink-0 text-text-success" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
77
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
78
+ </svg>
79
+ );
80
+ }
81
+
82
+ function PlanCard({
83
+ plan,
84
+ annual,
85
+ onSubscribe,
86
+ isAuthenticated,
87
+ isLoading,
88
+ }: {
89
+ plan: PlanTier;
90
+ annual: boolean;
91
+ onSubscribe: (priceId: string) => void;
92
+ isAuthenticated: boolean;
93
+ isLoading: boolean;
94
+ }) {
95
+ const price = annual ? plan.annualPrice : plan.monthlyPrice;
96
+ const period = annual ? '/year' : '/month';
97
+ const priceId = annual ? plan.annualPriceId : plan.priceId;
98
+ const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
99
+
100
+ return (
101
+ <Card
102
+ className={
103
+ plan.highlighted
104
+ ? 'relative ring-2 ring-brand-primary'
105
+ : 'relative'
106
+ }
107
+ >
108
+ {plan.highlighted && (
109
+ <div className="absolute -top-3 left-1/2 -translate-x-1/2">
110
+ <Badge variant="info">Most popular</Badge>
111
+ </div>
112
+ )}
113
+
114
+ <div className="flex flex-col h-full">
115
+ <div>
116
+ <h3 className="text-lg font-semibold text-text-primary">{plan.name}</h3>
117
+ <p className="mt-1 text-sm text-text-secondary">{plan.description}</p>
118
+ </div>
119
+
120
+ <div className="mt-4">
121
+ <span className="text-4xl font-bold text-text-primary">{formatPrice(price)}</span>
122
+ {price > 0 && (
123
+ <span className="ml-1 text-sm text-text-muted">{period}</span>
124
+ )}
125
+ </div>
126
+
127
+ <ul className="mt-6 space-y-3 flex-1">
128
+ {plan.features.map((feature) => (
129
+ <li key={feature} className="flex items-start gap-2">
130
+ <CheckIcon />
131
+ <span className="text-sm text-text-secondary">{feature}</span>
132
+ </li>
133
+ ))}
134
+ </ul>
135
+
136
+ <div className="mt-6">
137
+ {price === 0 ? (
138
+ isAuthenticated ? (
139
+ <LinkButton href={routes.dashboard} variant="secondary" size="md" className="w-full justify-center">
140
+ Go to Dashboard
141
+ </LinkButton>
142
+ ) : (
143
+ <LinkButton href={routes.signUp} variant="secondary" size="md" className="w-full justify-center">
144
+ {plan.cta}
145
+ </LinkButton>
146
+ )
147
+ ) : hasStripe && priceId ? (
148
+ <Button
149
+ variant={plan.highlighted ? 'primary' : 'secondary'}
150
+ size="md"
151
+ className="w-full justify-center"
152
+ onClick={() => onSubscribe(priceId)}
153
+ disabled={isLoading}
154
+ >
155
+ {isLoading ? 'Redirecting...' : plan.cta}
156
+ </Button>
157
+ ) : isAuthenticated ? (
158
+ <Button variant={plan.highlighted ? 'primary' : 'secondary'} size="md" className="w-full justify-center" disabled>
159
+ {plan.cta}
160
+ </Button>
161
+ ) : (
162
+ <LinkButton
163
+ href={`${routes.signIn}?callbackUrl=${encodeURIComponent(routes.pricing)}`}
164
+ variant={plan.highlighted ? 'primary' : 'secondary'}
165
+ size="md"
166
+ className="w-full justify-center"
167
+ >
168
+ Sign in to subscribe
169
+ </LinkButton>
170
+ )}
171
+ </div>
172
+ </div>
173
+ </Card>
174
+ );
175
+ }
176
+
177
+ export default function PricingPage() {
178
+ const [annual, setAnnual] = useState(false);
179
+ const [loadingPriceId, setLoadingPriceId] = useState<string | null>(null);
180
+ const { user } = useAuth();
181
+ const isAuthenticated = !!user;
182
+ const hasStripe = (appConfig.services.payments.provider as string) === 'stripe';
183
+
184
+ const handleSubscribe = async (priceId: string) => {
185
+ if (!isAuthenticated) {
186
+ window.location.href = `${routes.signIn}?callbackUrl=${encodeURIComponent(routes.pricing)}`;
187
+ return;
188
+ }
189
+
190
+ setLoadingPriceId(priceId);
191
+
192
+ try {
193
+ const response = await fetch('/api/protected/billing/checkout', {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ credentials: 'include',
197
+ body: JSON.stringify({ priceId }),
198
+ });
199
+
200
+ if (!response.ok) {
201
+ const data: { error?: string } = await response.json();
202
+ throw new Error(data.error ?? 'Failed to create checkout session');
203
+ }
204
+
205
+ const { url } = (await response.json()) as { url: string };
206
+ window.location.href = url;
207
+ } catch (error) {
208
+ console.error('Checkout error:', error);
209
+ setLoadingPriceId(null);
210
+ }
211
+ };
212
+
213
+ return (
214
+ <div className="min-h-screen bg-surface-background">
215
+ <div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:px-8">
216
+ <div className="text-center">
217
+ <h1 className="text-4xl font-bold tracking-tight text-text-primary sm:text-5xl">
218
+ Simple, transparent pricing
219
+ </h1>
220
+ <p className="mx-auto mt-4 max-w-2xl text-lg text-text-secondary">
221
+ Choose the plan that fits your needs. All plans include a 14-day free trial.
222
+ </p>
223
+ </div>
224
+
225
+ <div className="mt-8 flex items-center justify-center gap-3">
226
+ <span className={`text-sm font-medium ${!annual ? 'text-text-primary' : 'text-text-muted'}`}>
227
+ Monthly
228
+ </span>
229
+ <button
230
+ type="button"
231
+ role="switch"
232
+ aria-checked={annual}
233
+ onClick={() => setAnnual((prev) => !prev)}
234
+ className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
235
+ annual ? 'bg-brand-primary' : 'bg-surface-hover'
236
+ }`}
237
+ >
238
+ <span
239
+ className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-surface-card shadow-sm transition-transform duration-200 ${
240
+ annual ? 'translate-x-5' : 'translate-x-0'
241
+ }`}
242
+ />
243
+ </button>
244
+ <span className={`text-sm font-medium ${annual ? 'text-text-primary' : 'text-text-muted'}`}>
245
+ Annual
246
+ </span>
247
+ {annual && (
248
+ <Badge variant="success">Save ~17%</Badge>
249
+ )}
250
+ </div>
251
+
252
+ {!hasStripe && (
253
+ <div className="mx-auto mt-6 max-w-md rounded-lg border border-border-default bg-surface-card p-4 text-center">
254
+ <p className="text-sm text-text-muted">
255
+ Stripe is not configured. Showing placeholder plans. Configure your payments
256
+ provider to enable subscriptions.
257
+ </p>
258
+ </div>
259
+ )}
260
+
261
+ <div className="mt-12 grid gap-8 lg:grid-cols-3">
262
+ {PLACEHOLDER_PLANS.map((plan) => (
263
+ <PlanCard
264
+ key={plan.name}
265
+ plan={plan}
266
+ annual={annual}
267
+ onSubscribe={handleSubscribe}
268
+ isAuthenticated={isAuthenticated}
269
+ isLoading={loadingPriceId !== null}
270
+ />
271
+ ))}
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ }
@@ -7,6 +7,9 @@ export const routes = {
7
7
  verifyEmail: '/verify',
8
8
  dashboard: '/dashboard',
9
9
  settings: '/settings',
10
+ billing: '/settings/billing',
11
+ pricing: '/pricing',
12
+ files: '/files',
10
13
  admin: '/admin',
11
14
  onboarding: '/onboarding',
12
15
  comingSoon: '/coming-soon',
@@ -0,0 +1,202 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+ import { Button, Badge, Spinner } from '@mars-stack/ui';
5
+ import type { FileRecord } from '../types';
6
+
7
+ function formatFileSize(bytes: number): string {
8
+ if (bytes < 1024) return `${bytes} B`;
9
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
10
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
11
+ }
12
+
13
+ function formatDate(date: Date | string): string {
14
+ return new Date(date).toLocaleDateString(undefined, {
15
+ year: 'numeric',
16
+ month: 'short',
17
+ day: 'numeric',
18
+ });
19
+ }
20
+
21
+ function FileTypeIcon({ contentType }: { contentType: string }) {
22
+ const iconClass = 'h-5 w-5 text-text-muted';
23
+
24
+ if (contentType.startsWith('image/')) {
25
+ return (
26
+ <svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
27
+ <path strokeLinecap="round" strokeLinejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21Z" />
28
+ </svg>
29
+ );
30
+ }
31
+
32
+ if (contentType.startsWith('video/')) {
33
+ return (
34
+ <svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
35
+ <path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <svg className={iconClass} fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
42
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
43
+ </svg>
44
+ );
45
+ }
46
+
47
+ interface FileListProps {
48
+ refreshKey?: number;
49
+ }
50
+
51
+ export function FileList({ refreshKey }: FileListProps) {
52
+ const [files, setFiles] = useState<FileRecord[]>([]);
53
+ const [loading, setLoading] = useState(true);
54
+ const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
55
+
56
+ const fetchFiles = useCallback(async () => {
57
+ try {
58
+ const response = await fetch('/api/protected/files/list', {
59
+ credentials: 'include',
60
+ });
61
+
62
+ if (response.ok) {
63
+ const data = (await response.json()) as { files: FileRecord[] };
64
+ setFiles(data.files);
65
+ }
66
+ } catch (error) {
67
+ console.error('Failed to fetch files:', error);
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ }, []);
72
+
73
+ useEffect(() => {
74
+ fetchFiles();
75
+ }, [fetchFiles, refreshKey]);
76
+
77
+ const handleDelete = useCallback(async (fileId: string) => {
78
+ setDeletingIds((prev) => new Set(prev).add(fileId));
79
+ setFiles((prev) => prev.filter((f) => f.id !== fileId));
80
+
81
+ try {
82
+ const response = await fetch(`/api/protected/files/${fileId}`, {
83
+ method: 'DELETE',
84
+ credentials: 'include',
85
+ });
86
+
87
+ if (!response.ok) {
88
+ const restored = files.find((f) => f.id === fileId);
89
+ if (restored) {
90
+ setFiles((prev) => [...prev, restored]);
91
+ }
92
+ }
93
+ } catch {
94
+ const restored = files.find((f) => f.id === fileId);
95
+ if (restored) {
96
+ setFiles((prev) => [...prev, restored]);
97
+ }
98
+ } finally {
99
+ setDeletingIds((prev) => {
100
+ const next = new Set(prev);
101
+ next.delete(fileId);
102
+ return next;
103
+ });
104
+ }
105
+ }, [files]);
106
+
107
+ if (loading) {
108
+ return (
109
+ <div className="flex items-center justify-center py-12">
110
+ <Spinner size="lg" />
111
+ </div>
112
+ );
113
+ }
114
+
115
+ if (files.length === 0) {
116
+ return (
117
+ <div className="rounded-xl border border-border-default bg-surface-card p-8 text-center">
118
+ <svg
119
+ className="mx-auto h-10 w-10 text-text-muted"
120
+ fill="none"
121
+ viewBox="0 0 24 24"
122
+ strokeWidth={1}
123
+ stroke="currentColor"
124
+ >
125
+ <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
126
+ </svg>
127
+ <p className="mt-3 text-sm text-text-secondary">No files uploaded yet.</p>
128
+ <p className="mt-1 text-xs text-text-muted">Drop a file above to get started.</p>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ return (
134
+ <div className="overflow-hidden rounded-xl border border-border-default">
135
+ <table className="w-full">
136
+ <thead>
137
+ <tr className="border-b border-border-default bg-surface-card">
138
+ <th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted">
139
+ File
140
+ </th>
141
+ <th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted sm:table-cell">
142
+ Size
143
+ </th>
144
+ <th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted md:table-cell">
145
+ Date
146
+ </th>
147
+ <th className="hidden px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-text-muted sm:table-cell">
148
+ Access
149
+ </th>
150
+ <th className="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-text-muted">
151
+ Actions
152
+ </th>
153
+ </tr>
154
+ </thead>
155
+ <tbody className="divide-y divide-border-default bg-surface-background">
156
+ {files.map((file) => (
157
+ <tr key={file.id} className="hover:bg-surface-hover transition-colors">
158
+ <td className="px-4 py-3">
159
+ <div className="flex items-center gap-2.5">
160
+ <FileTypeIcon contentType={file.contentType} />
161
+ <span className="truncate text-sm font-medium text-text-primary max-w-[200px]">
162
+ {file.filename}
163
+ </span>
164
+ </div>
165
+ </td>
166
+ <td className="hidden px-4 py-3 text-sm text-text-secondary sm:table-cell">
167
+ {formatFileSize(file.size)}
168
+ </td>
169
+ <td className="hidden px-4 py-3 text-sm text-text-secondary md:table-cell">
170
+ {formatDate(file.createdAt)}
171
+ </td>
172
+ <td className="hidden px-4 py-3 sm:table-cell">
173
+ <Badge variant={file.access === 'public' ? 'info' : 'neutral'}>
174
+ {file.access}
175
+ </Badge>
176
+ </td>
177
+ <td className="px-4 py-3">
178
+ <div className="flex items-center justify-end gap-2">
179
+ <a
180
+ href={`/api/protected/files/${file.id}`}
181
+ download
182
+ className="text-sm font-medium text-text-link hover:text-text-link-hover"
183
+ >
184
+ Download
185
+ </a>
186
+ <Button
187
+ variant="danger"
188
+ size="sm"
189
+ onClick={() => handleDelete(file.id)}
190
+ disabled={deletingIds.has(file.id)}
191
+ >
192
+ Delete
193
+ </Button>
194
+ </div>
195
+ </td>
196
+ </tr>
197
+ ))}
198
+ </tbody>
199
+ </table>
200
+ </div>
201
+ );
202
+ }