@hyperyai/sdk 1.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.
- package/LICENSE +13 -0
- package/README.md +391 -0
- package/dist/components/AuthButton.d.ts +51 -0
- package/dist/components/AuthButton.js +60 -0
- package/dist/components/AuthModal.d.ts +20 -0
- package/dist/components/AuthModal.js +62 -0
- package/dist/components/BuyButton.d.ts +47 -0
- package/dist/components/BuyButton.js +74 -0
- package/dist/components/ErrorBoundary.d.ts +13 -0
- package/dist/components/ErrorBoundary.js +24 -0
- package/dist/components/HyperyModals.d.ts +30 -0
- package/dist/components/HyperyModals.js +30 -0
- package/dist/components/InsufficientCreditsAlert.d.ts +11 -0
- package/dist/components/InsufficientCreditsAlert.js +12 -0
- package/dist/components/ModernAuthForm.d.ts +52 -0
- package/dist/components/ModernAuthForm.js +83 -0
- package/dist/components/RestrictionModal.d.ts +43 -0
- package/dist/components/RestrictionModal.js +167 -0
- package/dist/components/SignIn.d.ts +26 -0
- package/dist/components/SignIn.js +47 -0
- package/dist/components/SignInForm.d.ts +41 -0
- package/dist/components/SignInForm.js +86 -0
- package/dist/components/SignUp.d.ts +26 -0
- package/dist/components/SignUp.js +52 -0
- package/dist/components/SpendingLimitAlert.d.ts +12 -0
- package/dist/components/SpendingLimitAlert.js +14 -0
- package/dist/components/UserButton.d.ts +26 -0
- package/dist/components/UserButton.js +35 -0
- package/dist/components/UserProfile.d.ts +22 -0
- package/dist/components/UserProfile.js +26 -0
- package/dist/components/WorkspaceSwitcher.d.ts +29 -0
- package/dist/components/WorkspaceSwitcher.js +66 -0
- package/dist/components/control.d.ts +64 -0
- package/dist/components/control.js +89 -0
- package/dist/components/ui/dialog.d.ts +19 -0
- package/dist/components/ui/dialog.js +53 -0
- package/dist/hooks/index.d.ts +22 -0
- package/dist/hooks/index.js +23 -0
- package/dist/hooks/useBuyerWallet.d.ts +37 -0
- package/dist/hooks/useBuyerWallet.js +66 -0
- package/dist/hooks/useCheckout.d.ts +27 -0
- package/dist/hooks/useCheckout.js +246 -0
- package/dist/hooks/useError.d.ts +19 -0
- package/dist/hooks/useError.js +36 -0
- package/dist/hooks/useMarketplace.d.ts +50 -0
- package/dist/hooks/useMarketplace.js +69 -0
- package/dist/hooks/useMemberships.d.ts +71 -0
- package/dist/hooks/useMemberships.js +138 -0
- package/dist/hooks/useWallet.d.ts +62 -0
- package/dist/hooks/useWallet.js +123 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +52 -0
- package/dist/lib/context.d.ts +50 -0
- package/dist/lib/context.js +409 -0
- package/dist/lib/oauth.d.ts +49 -0
- package/dist/lib/oauth.js +149 -0
- package/dist/lib/parse-error.d.ts +45 -0
- package/dist/lib/parse-error.js +185 -0
- package/dist/lib/parse-stream.d.ts +43 -0
- package/dist/lib/parse-stream.js +89 -0
- package/dist/lib/popup.d.ts +42 -0
- package/dist/lib/popup.js +80 -0
- package/dist/lib/storage.d.ts +43 -0
- package/dist/lib/storage.js +125 -0
- package/dist/lib/utils.d.ts +8 -0
- package/dist/lib/utils.js +11 -0
- package/dist/types/index.d.ts +191 -0
- package/dist/types/index.js +4 -0
- package/package.json +78 -0
- package/src/components/AuthButton.tsx +123 -0
- package/src/components/AuthModal.tsx +240 -0
- package/src/components/BuyButton.tsx +150 -0
- package/src/components/ErrorBoundary.tsx +97 -0
- package/src/components/HyperyModals.tsx +83 -0
- package/src/components/InsufficientCreditsAlert.tsx +73 -0
- package/src/components/ModernAuthForm.tsx +322 -0
- package/src/components/RestrictionModal.tsx +373 -0
- package/src/components/SignIn.tsx +84 -0
- package/src/components/SignInForm.tsx +256 -0
- package/src/components/SignUp.tsx +86 -0
- package/src/components/SpendingLimitAlert.tsx +91 -0
- package/src/components/UserButton.tsx +106 -0
- package/src/components/UserProfile.tsx +106 -0
- package/src/components/WorkspaceSwitcher.tsx +199 -0
- package/src/components/control.tsx +133 -0
- package/src/components/ui/dialog.tsx +151 -0
- package/src/hooks/index.ts +65 -0
- package/src/hooks/useBuyerWallet.ts +117 -0
- package/src/hooks/useCheckout.ts +312 -0
- package/src/hooks/useError.ts +60 -0
- package/src/hooks/useMarketplace.ts +129 -0
- package/src/hooks/useMemberships.ts +203 -0
- package/src/hooks/useWallet.ts +170 -0
- package/src/index.ts +147 -0
- package/src/lib/context.tsx +478 -0
- package/src/lib/oauth.ts +215 -0
- package/src/lib/parse-error.ts +224 -0
- package/src/lib/parse-stream.ts +121 -0
- package/src/lib/popup.ts +98 -0
- package/src/lib/storage.ts +140 -0
- package/src/lib/utils.ts +14 -0
- package/src/types/index.ts +216 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ModernAuthForm Component
|
|
3
|
+
* Beautiful, embeddable authentication form
|
|
4
|
+
* Inspired by modern SaaS login pages
|
|
5
|
+
* Can be used standalone on login/signup pages
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
'use client';
|
|
9
|
+
|
|
10
|
+
import React, { useState } from 'react';
|
|
11
|
+
import { useHyperyAuth } from '../lib/context';
|
|
12
|
+
|
|
13
|
+
export interface ModernAuthFormProps {
|
|
14
|
+
/** Auth mode: signin or signup */
|
|
15
|
+
mode?: 'signin' | 'signup';
|
|
16
|
+
/** Allow switching between signin/signup */
|
|
17
|
+
allowModeSwitch?: boolean;
|
|
18
|
+
/** Custom className */
|
|
19
|
+
className?: string;
|
|
20
|
+
/** Show card wrapper */
|
|
21
|
+
showCard?: boolean;
|
|
22
|
+
/** Show social OAuth buttons */
|
|
23
|
+
showSocial?: boolean;
|
|
24
|
+
/** Show email/password form */
|
|
25
|
+
showEmailPassword?: boolean;
|
|
26
|
+
/** Callback after successful auth */
|
|
27
|
+
onSuccess?: () => void;
|
|
28
|
+
/** Callback on error */
|
|
29
|
+
onError?: (error: string) => void;
|
|
30
|
+
/** Custom branding */
|
|
31
|
+
branding?: {
|
|
32
|
+
logo?: string;
|
|
33
|
+
appName?: string;
|
|
34
|
+
primaryColor?: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Modern authentication form with beautiful design
|
|
40
|
+
* Perfect for dedicated login/signup pages
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* // On login page
|
|
45
|
+
* <ModernAuthForm
|
|
46
|
+
* mode="signin"
|
|
47
|
+
* showCard
|
|
48
|
+
* allowModeSwitch
|
|
49
|
+
* onSuccess={() => router.push('/dashboard')}
|
|
50
|
+
* branding={{
|
|
51
|
+
* logo: '/logo.png',
|
|
52
|
+
* appName: 'My App',
|
|
53
|
+
* primaryColor: '#8b5cf6'
|
|
54
|
+
* }}
|
|
55
|
+
* />
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export function ModernAuthForm({
|
|
59
|
+
mode: initialMode = 'signin',
|
|
60
|
+
allowModeSwitch = true,
|
|
61
|
+
className = '',
|
|
62
|
+
showCard = true,
|
|
63
|
+
showSocial = true,
|
|
64
|
+
showEmailPassword = true,
|
|
65
|
+
onSuccess,
|
|
66
|
+
onError,
|
|
67
|
+
branding,
|
|
68
|
+
}: ModernAuthFormProps) {
|
|
69
|
+
const { login } = useHyperyAuth();
|
|
70
|
+
const [mode, setMode] = useState<'signin' | 'signup'>(initialMode);
|
|
71
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
72
|
+
const [loadingProvider, setLoadingProvider] = useState<string | null>(null);
|
|
73
|
+
const [email, setEmail] = useState('');
|
|
74
|
+
const [password, setPassword] = useState('');
|
|
75
|
+
const [name, setName] = useState('');
|
|
76
|
+
const [error, setError] = useState('');
|
|
77
|
+
|
|
78
|
+
const handleSocialSignIn = (provider: 'google' | 'github') => {
|
|
79
|
+
setLoadingProvider(provider);
|
|
80
|
+
setError('');
|
|
81
|
+
try {
|
|
82
|
+
login();
|
|
83
|
+
if (onSuccess) {
|
|
84
|
+
onSuccess();
|
|
85
|
+
}
|
|
86
|
+
} catch (err) {
|
|
87
|
+
const errorMessage = err instanceof Error ? err.message : `${provider} sign in failed`;
|
|
88
|
+
setError(errorMessage);
|
|
89
|
+
if (onError) {
|
|
90
|
+
onError(errorMessage);
|
|
91
|
+
}
|
|
92
|
+
setLoadingProvider(null);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const handleEmailSubmit = (e: React.FormEvent) => {
|
|
97
|
+
e.preventDefault();
|
|
98
|
+
setError('');
|
|
99
|
+
setIsLoading(true);
|
|
100
|
+
|
|
101
|
+
if (!email || !password) {
|
|
102
|
+
setError('Please fill in all fields');
|
|
103
|
+
setIsLoading(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (mode === 'signup' && !name) {
|
|
108
|
+
setError('Please enter your name');
|
|
109
|
+
setIsLoading(false);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
setError('Direct email authentication coming soon. Please use social sign-in.');
|
|
114
|
+
setIsLoading(false);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const primaryColor = branding?.primaryColor || '#8b5cf6';
|
|
118
|
+
|
|
119
|
+
const formContent = (
|
|
120
|
+
<div className={!showCard ? className : ''}>
|
|
121
|
+
{/* Header */}
|
|
122
|
+
<div className="text-center mb-8">
|
|
123
|
+
{branding?.logo && (
|
|
124
|
+
<img src={branding.logo} alt="Logo" className="h-12 mx-auto mb-6" />
|
|
125
|
+
)}
|
|
126
|
+
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-3">
|
|
127
|
+
{mode === 'signin' ? 'Welcome back' : 'Create your account'}
|
|
128
|
+
</h1>
|
|
129
|
+
<p className="text-gray-600 dark:text-gray-400">
|
|
130
|
+
{mode === 'signin'
|
|
131
|
+
? `Sign in to ${branding?.appName || 'continue'}`
|
|
132
|
+
: `Get started with ${branding?.appName || 'your account'}`
|
|
133
|
+
}
|
|
134
|
+
</p>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
{/* Error message */}
|
|
138
|
+
{error && (
|
|
139
|
+
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-800 dark:text-red-200 text-sm flex items-start gap-3">
|
|
140
|
+
<svg className="w-5 h-5 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
|
141
|
+
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
|
142
|
+
</svg>
|
|
143
|
+
<span>{error}</span>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{/* Social buttons */}
|
|
148
|
+
{showSocial && (
|
|
149
|
+
<div className="space-y-3 mb-6">
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => handleSocialSignIn('google')}
|
|
152
|
+
disabled={!!loadingProvider}
|
|
153
|
+
className="w-full bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white py-3.5 px-4 rounded-xl flex items-center justify-center gap-3 font-medium transition-all disabled:opacity-50 shadow-sm hover:shadow-md"
|
|
154
|
+
>
|
|
155
|
+
{loadingProvider === 'google' ? (
|
|
156
|
+
<div className="w-5 h-5 border-2 border-gray-300 border-t-transparent rounded-full animate-spin" />
|
|
157
|
+
) : (
|
|
158
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
|
159
|
+
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
|
160
|
+
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
|
161
|
+
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
|
162
|
+
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
|
163
|
+
</svg>
|
|
164
|
+
)}
|
|
165
|
+
<span>Continue with Google</span>
|
|
166
|
+
</button>
|
|
167
|
+
|
|
168
|
+
<button
|
|
169
|
+
onClick={() => handleSocialSignIn('github')}
|
|
170
|
+
disabled={!!loadingProvider}
|
|
171
|
+
className="w-full bg-gray-900 dark:bg-gray-800 hover:bg-gray-800 dark:hover:bg-gray-750 text-white py-3.5 px-4 rounded-xl flex items-center justify-center gap-3 font-medium transition-all disabled:opacity-50 shadow-sm hover:shadow-md"
|
|
172
|
+
>
|
|
173
|
+
{loadingProvider === 'github' ? (
|
|
174
|
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
175
|
+
) : (
|
|
176
|
+
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
|
177
|
+
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
|
|
178
|
+
</svg>
|
|
179
|
+
)}
|
|
180
|
+
<span>Continue with GitHub</span>
|
|
181
|
+
</button>
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
|
|
185
|
+
{/* Divider */}
|
|
186
|
+
{showSocial && showEmailPassword && (
|
|
187
|
+
<div className="relative mb-6">
|
|
188
|
+
<div className="absolute inset-0 flex items-center">
|
|
189
|
+
<div className="w-full border-t border-gray-300 dark:border-gray-700" />
|
|
190
|
+
</div>
|
|
191
|
+
<div className="relative flex justify-center text-sm">
|
|
192
|
+
<span className="px-4 bg-white dark:bg-gray-900 text-gray-500 dark:text-gray-400 font-medium">
|
|
193
|
+
Or continue with email
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
|
|
199
|
+
{/* Email/Password form */}
|
|
200
|
+
{showEmailPassword && (
|
|
201
|
+
<form onSubmit={handleEmailSubmit} className="space-y-5">
|
|
202
|
+
{mode === 'signup' && (
|
|
203
|
+
<div>
|
|
204
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
205
|
+
Full name
|
|
206
|
+
</label>
|
|
207
|
+
<input
|
|
208
|
+
type="text"
|
|
209
|
+
value={name}
|
|
210
|
+
onChange={(e) => setName(e.target.value)}
|
|
211
|
+
className="w-full px-4 py-3.5 rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-offset-2 focus:outline-none transition-all"
|
|
212
|
+
style={{ '--tw-ring-color': primaryColor } as React.CSSProperties}
|
|
213
|
+
placeholder="John Doe"
|
|
214
|
+
disabled={isLoading}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<div>
|
|
220
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
221
|
+
Email address
|
|
222
|
+
</label>
|
|
223
|
+
<input
|
|
224
|
+
type="email"
|
|
225
|
+
value={email}
|
|
226
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
227
|
+
className="w-full px-4 py-3.5 rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-offset-2 focus:outline-none transition-all"
|
|
228
|
+
style={{ '--tw-ring-color': primaryColor } as React.CSSProperties}
|
|
229
|
+
placeholder="you@example.com"
|
|
230
|
+
disabled={isLoading}
|
|
231
|
+
autoComplete="email"
|
|
232
|
+
/>
|
|
233
|
+
</div>
|
|
234
|
+
|
|
235
|
+
<div>
|
|
236
|
+
<div className="flex items-center justify-between mb-2">
|
|
237
|
+
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
|
238
|
+
Password
|
|
239
|
+
</label>
|
|
240
|
+
{mode === 'signin' && (
|
|
241
|
+
<a
|
|
242
|
+
href="/forgot-password"
|
|
243
|
+
className="text-sm font-medium hover:underline"
|
|
244
|
+
style={{ color: primaryColor }}
|
|
245
|
+
>
|
|
246
|
+
Forgot?
|
|
247
|
+
</a>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
<input
|
|
251
|
+
type="password"
|
|
252
|
+
value={password}
|
|
253
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
254
|
+
className="w-full px-4 py-3.5 rounded-xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-offset-2 focus:outline-none transition-all"
|
|
255
|
+
style={{ '--tw-ring-color': primaryColor } as React.CSSProperties}
|
|
256
|
+
placeholder="••••••••"
|
|
257
|
+
disabled={isLoading}
|
|
258
|
+
autoComplete={mode === 'signin' ? 'current-password' : 'new-password'}
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<button
|
|
263
|
+
type="submit"
|
|
264
|
+
disabled={isLoading}
|
|
265
|
+
className="w-full py-3.5 px-4 rounded-xl font-semibold text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed transform hover:scale-[1.02] active:scale-[0.98]"
|
|
266
|
+
style={{ backgroundColor: primaryColor }}
|
|
267
|
+
>
|
|
268
|
+
{isLoading ? (
|
|
269
|
+
<span className="flex items-center justify-center gap-2">
|
|
270
|
+
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
271
|
+
{mode === 'signin' ? 'Signing in...' : 'Creating account...'}
|
|
272
|
+
</span>
|
|
273
|
+
) : (
|
|
274
|
+
mode === 'signin' ? 'Sign in' : 'Create account'
|
|
275
|
+
)}
|
|
276
|
+
</button>
|
|
277
|
+
</form>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{/* Mode switcher */}
|
|
281
|
+
{allowModeSwitch && (
|
|
282
|
+
<div className="mt-6 text-center text-sm">
|
|
283
|
+
<span className="text-gray-600 dark:text-gray-400">
|
|
284
|
+
{mode === 'signin' ? "Don't have an account?" : 'Already have an account?'}
|
|
285
|
+
</span>
|
|
286
|
+
{' '}
|
|
287
|
+
<button
|
|
288
|
+
onClick={() => setMode(mode === 'signin' ? 'signup' : 'signin')}
|
|
289
|
+
className="font-semibold hover:underline"
|
|
290
|
+
style={{ color: primaryColor }}
|
|
291
|
+
>
|
|
292
|
+
{mode === 'signin' ? 'Sign up' : 'Sign in'}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{/* Footer */}
|
|
298
|
+
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-800 text-center text-xs text-gray-500 dark:text-gray-500">
|
|
299
|
+
By continuing, you agree to our{' '}
|
|
300
|
+
<a href="/terms" className="hover:underline font-medium" style={{ color: primaryColor }}>
|
|
301
|
+
Terms of Service
|
|
302
|
+
</a>
|
|
303
|
+
{' '}and{' '}
|
|
304
|
+
<a href="/privacy" className="hover:underline font-medium" style={{ color: primaryColor }}>
|
|
305
|
+
Privacy Policy
|
|
306
|
+
</a>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
if (!showCard) {
|
|
312
|
+
return <div className={className}>{formContent}</div>;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<div className={`bg-white dark:bg-gray-900 rounded-2xl shadow-2xl p-10 max-w-md w-full ${className}`}>
|
|
317
|
+
{formContent}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface RestrictionError {
|
|
6
|
+
code: string;
|
|
7
|
+
message: string;
|
|
8
|
+
type?: string;
|
|
9
|
+
// Spending limit specific
|
|
10
|
+
limitType?: 'daily' | 'monthly' | 'total';
|
|
11
|
+
limit?: number;
|
|
12
|
+
current?: number;
|
|
13
|
+
requested?: number;
|
|
14
|
+
resetsAt?: string;
|
|
15
|
+
// Insufficient credits specific
|
|
16
|
+
available?: number;
|
|
17
|
+
required?: number;
|
|
18
|
+
// Generic extensible fields
|
|
19
|
+
[key: string]: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type BillingMode = 'metered' | 'prepaid';
|
|
23
|
+
|
|
24
|
+
interface WalletState {
|
|
25
|
+
mode: BillingMode;
|
|
26
|
+
balance: { current: number; reserved: number; monthlySpent: number; monthlyLimit: number };
|
|
27
|
+
paymentMethod: { exists: boolean; last4?: string; brand?: string };
|
|
28
|
+
autoTopUp: { enabled: boolean; threshold?: number; amount?: number };
|
|
29
|
+
lowBalance: { isLow: boolean; threshold: number; current: number };
|
|
30
|
+
topupTiers: Array<{ name: string; usdAmount: number; credits: number; bonus: number; popular?: boolean }>;
|
|
31
|
+
settingsUrls: { billing: string; topup: string; addPaymentMethod: string };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RestrictionModalProps {
|
|
35
|
+
error: RestrictionError | null;
|
|
36
|
+
/** OAuth clientId of the app making the requests. Preferred name. */
|
|
37
|
+
clientId?: string;
|
|
38
|
+
/**
|
|
39
|
+
* @deprecated Use `clientId` instead. This prop is actually the OAuth clientId,
|
|
40
|
+
* not the DB app id. Kept as a backward-compatible alias.
|
|
41
|
+
*/
|
|
42
|
+
appId?: string;
|
|
43
|
+
gatewayUrl: string;
|
|
44
|
+
getAccessToken: () => Promise<string | null>;
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
onRetry?: () => void;
|
|
47
|
+
/** Called after funds/card are successfully added. */
|
|
48
|
+
onFunded?: () => void;
|
|
49
|
+
className?: string;
|
|
50
|
+
overlayClassName?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const QUICK_AMOUNTS = [10, 25, 50];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mode-aware restriction / funds modal.
|
|
57
|
+
*
|
|
58
|
+
* Reads the team's wallet state (GET /api/wallet/state) and renders the CTA that
|
|
59
|
+
* matches the billing lever:
|
|
60
|
+
* - metered + no card → "Add a payment method" (Stripe-hosted Checkout)
|
|
61
|
+
* - metered + declined → "Update payment method"
|
|
62
|
+
* - prepaid → 1-click "Add $X" (charges the saved card in place)
|
|
63
|
+
*/
|
|
64
|
+
export function RestrictionModal({
|
|
65
|
+
error,
|
|
66
|
+
clientId,
|
|
67
|
+
appId,
|
|
68
|
+
gatewayUrl,
|
|
69
|
+
getAccessToken,
|
|
70
|
+
onClose,
|
|
71
|
+
onRetry,
|
|
72
|
+
onFunded,
|
|
73
|
+
className = '',
|
|
74
|
+
overlayClassName = '',
|
|
75
|
+
}: RestrictionModalProps) {
|
|
76
|
+
// `appId` is a deprecated alias for `clientId`; prefer `clientId` when both are set.
|
|
77
|
+
const resolvedClientId = clientId ?? appId;
|
|
78
|
+
const [wallet, setWallet] = useState<WalletState | null>(null);
|
|
79
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
80
|
+
const [fetchError, setFetchError] = useState<string | null>(null);
|
|
81
|
+
const [busy, setBusy] = useState(false);
|
|
82
|
+
const [actionError, setActionError] = useState<string | null>(null);
|
|
83
|
+
const [funded, setFunded] = useState(false);
|
|
84
|
+
|
|
85
|
+
const authHeaders = useCallback(async () => {
|
|
86
|
+
const token = await getAccessToken();
|
|
87
|
+
if (!token) throw new Error('Not authenticated');
|
|
88
|
+
const headers: Record<string, string> = {
|
|
89
|
+
Authorization: `Bearer ${token}`,
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
};
|
|
92
|
+
if (resolvedClientId) headers['X-Hypery-Client-Id'] = resolvedClientId;
|
|
93
|
+
return headers;
|
|
94
|
+
}, [getAccessToken, resolvedClientId]);
|
|
95
|
+
|
|
96
|
+
const loadWallet = useCallback(async () => {
|
|
97
|
+
try {
|
|
98
|
+
setIsLoading(true);
|
|
99
|
+
setFetchError(null);
|
|
100
|
+
const headers = await authHeaders();
|
|
101
|
+
const res = await fetch(`${gatewayUrl}/api/wallet/state`, { headers });
|
|
102
|
+
if (!res.ok) throw new Error(`Failed to load wallet (${res.status})`);
|
|
103
|
+
setWallet((await res.json()) as WalletState);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
setFetchError(err instanceof Error ? err.message : 'Failed to load wallet');
|
|
106
|
+
} finally {
|
|
107
|
+
setIsLoading(false);
|
|
108
|
+
}
|
|
109
|
+
}, [authHeaders, gatewayUrl]);
|
|
110
|
+
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!error) return;
|
|
113
|
+
void loadWallet();
|
|
114
|
+
}, [error, loadWallet]);
|
|
115
|
+
|
|
116
|
+
// 1-click: charge the saved card and credit the team.
|
|
117
|
+
const addFunds = useCallback(
|
|
118
|
+
async (usd: number) => {
|
|
119
|
+
setBusy(true);
|
|
120
|
+
setActionError(null);
|
|
121
|
+
try {
|
|
122
|
+
const headers = await authHeaders();
|
|
123
|
+
const res = await fetch(`${gatewayUrl}/api/wallet/topup`, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers,
|
|
126
|
+
body: JSON.stringify({ amount: usd }),
|
|
127
|
+
});
|
|
128
|
+
const body = await res.json().catch(() => ({}));
|
|
129
|
+
if (!res.ok || !body?.success) {
|
|
130
|
+
throw new Error(body?.error || `Top-up failed (${res.status})`);
|
|
131
|
+
}
|
|
132
|
+
await loadWallet();
|
|
133
|
+
setFunded(true);
|
|
134
|
+
onFunded?.();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
setActionError(err instanceof Error ? err.message : 'Top-up failed');
|
|
137
|
+
} finally {
|
|
138
|
+
setBusy(false);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[authHeaders, gatewayUrl, loadWallet, onFunded]
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Stripe-hosted Checkout (setup) in a popup — no Stripe Elements here.
|
|
145
|
+
const addPaymentMethod = useCallback(async () => {
|
|
146
|
+
setBusy(true);
|
|
147
|
+
setActionError(null);
|
|
148
|
+
try {
|
|
149
|
+
const headers = await authHeaders();
|
|
150
|
+
const res = await fetch(`${gatewayUrl}/api/payments/stripe/checkout-setup`, {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify({}),
|
|
154
|
+
});
|
|
155
|
+
const body = await res.json().catch(() => ({}));
|
|
156
|
+
if (!res.ok || !body?.url) {
|
|
157
|
+
throw new Error(body?.error || `Could not start checkout (${res.status})`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const popup = window.open(body.url, 'hypery-add-card', 'width=480,height=720');
|
|
161
|
+
|
|
162
|
+
const added = await new Promise<boolean>((resolve) => {
|
|
163
|
+
let settled = false;
|
|
164
|
+
const finish = (ok: boolean) => {
|
|
165
|
+
if (settled) return;
|
|
166
|
+
settled = true;
|
|
167
|
+
window.removeEventListener('message', onMessage);
|
|
168
|
+
clearInterval(poll);
|
|
169
|
+
resolve(ok);
|
|
170
|
+
};
|
|
171
|
+
// Only trust the completion message from the gateway's own origin.
|
|
172
|
+
const expectedOrigin = (() => {
|
|
173
|
+
try {
|
|
174
|
+
return new URL(gatewayUrl).origin;
|
|
175
|
+
} catch {
|
|
176
|
+
return typeof window !== 'undefined' ? window.location.origin : '';
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
const onMessage = (event: MessageEvent) => {
|
|
180
|
+
if (event.origin !== expectedOrigin) return;
|
|
181
|
+
if (event?.data?.type === 'hypery:payment-method') {
|
|
182
|
+
finish(event.data.status === 'added');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
window.addEventListener('message', onMessage);
|
|
186
|
+
const poll = setInterval(() => {
|
|
187
|
+
if (popup && popup.closed) finish(false);
|
|
188
|
+
}, 700);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await loadWallet();
|
|
192
|
+
if (added) onFunded?.();
|
|
193
|
+
} catch (err) {
|
|
194
|
+
setActionError(err instanceof Error ? err.message : 'Could not add payment method');
|
|
195
|
+
} finally {
|
|
196
|
+
setBusy(false);
|
|
197
|
+
}
|
|
198
|
+
}, [authHeaders, gatewayUrl, loadWallet, onFunded]);
|
|
199
|
+
|
|
200
|
+
if (!error) return null;
|
|
201
|
+
|
|
202
|
+
const openSettings = (path?: string) => {
|
|
203
|
+
if (!path) return;
|
|
204
|
+
window.open(`${gatewayUrl}${path}`, '_blank');
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const isSpendingLimit = error.code === 'SPENDING_LIMIT_EXCEEDED';
|
|
208
|
+
const isPaymentDeclined = error.code === 'PAYMENT_DECLINED';
|
|
209
|
+
const isPaymentMethodRequired = error.code === 'PAYMENT_METHOD_REQUIRED';
|
|
210
|
+
|
|
211
|
+
const title = funded
|
|
212
|
+
? 'You’re all set'
|
|
213
|
+
: isPaymentMethodRequired
|
|
214
|
+
? 'Add a payment method'
|
|
215
|
+
: isPaymentDeclined
|
|
216
|
+
? 'Payment failed'
|
|
217
|
+
: isSpendingLimit
|
|
218
|
+
? 'Spending limit reached'
|
|
219
|
+
: 'Add funds';
|
|
220
|
+
|
|
221
|
+
const mode = wallet?.mode;
|
|
222
|
+
const hasCard = !!wallet?.paymentMethod?.exists;
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm ${overlayClassName}`}>
|
|
226
|
+
<div className={`relative w-full max-w-md bg-gray-900 border border-gray-700 rounded-lg shadow-2xl ${className}`}>
|
|
227
|
+
<button
|
|
228
|
+
onClick={onClose}
|
|
229
|
+
className="absolute top-4 right-4 text-gray-400 hover:text-gray-100 transition-colors z-10"
|
|
230
|
+
aria-label="Close"
|
|
231
|
+
>
|
|
232
|
+
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
233
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
234
|
+
</svg>
|
|
235
|
+
</button>
|
|
236
|
+
|
|
237
|
+
<div className="p-6">
|
|
238
|
+
<h2 className="text-2xl font-bold text-center mb-4 text-white">{title}</h2>
|
|
239
|
+
|
|
240
|
+
{isLoading && (
|
|
241
|
+
<div className="flex items-center justify-center py-8">
|
|
242
|
+
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
|
243
|
+
<span className="ml-3 text-gray-400">Loading your account…</span>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{!isLoading && (
|
|
248
|
+
<>
|
|
249
|
+
{/* Wallet summary */}
|
|
250
|
+
{wallet && (
|
|
251
|
+
<div className="bg-gray-800 border border-gray-700 rounded-lg p-4 mb-4">
|
|
252
|
+
<div className="flex justify-between items-center">
|
|
253
|
+
<span className="text-sm text-gray-400">Available credits</span>
|
|
254
|
+
<span className="text-2xl font-bold text-white">{wallet.balance.current}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="flex justify-between items-center mt-1">
|
|
257
|
+
<span className="text-xs text-gray-500">
|
|
258
|
+
{mode === 'metered' ? 'Pay-as-you-go billing' : 'Prepaid balance'}
|
|
259
|
+
</span>
|
|
260
|
+
{wallet.paymentMethod.exists && (
|
|
261
|
+
<span className="text-xs text-gray-500">
|
|
262
|
+
{wallet.paymentMethod.brand} •••• {wallet.paymentMethod.last4}
|
|
263
|
+
</span>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
|
|
269
|
+
{fetchError && (
|
|
270
|
+
<p className="text-sm text-red-400 mb-4">⚠️ {fetchError}</p>
|
|
271
|
+
)}
|
|
272
|
+
{actionError && (
|
|
273
|
+
<p className="text-sm text-red-400 mb-4">⚠️ {actionError}</p>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Context message */}
|
|
277
|
+
{!funded && (
|
|
278
|
+
<p className="text-sm text-gray-400 text-center mb-5">{error.message}</p>
|
|
279
|
+
)}
|
|
280
|
+
|
|
281
|
+
{/* Actions */}
|
|
282
|
+
<div className="space-y-3">
|
|
283
|
+
{funded ? (
|
|
284
|
+
<button
|
|
285
|
+
onClick={() => {
|
|
286
|
+
onRetry?.();
|
|
287
|
+
onClose();
|
|
288
|
+
}}
|
|
289
|
+
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
|
|
290
|
+
>
|
|
291
|
+
Continue
|
|
292
|
+
</button>
|
|
293
|
+
) : isSpendingLimit ? (
|
|
294
|
+
<>
|
|
295
|
+
<button
|
|
296
|
+
onClick={() => openSettings(wallet?.settingsUrls.billing)}
|
|
297
|
+
className="w-full px-4 py-3 bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg transition-colors"
|
|
298
|
+
>
|
|
299
|
+
Manage spending limits
|
|
300
|
+
</button>
|
|
301
|
+
{onRetry && (
|
|
302
|
+
<button
|
|
303
|
+
onClick={onRetry}
|
|
304
|
+
className="w-full px-4 py-3 bg-gray-800 hover:bg-gray-700 text-white border border-gray-600 rounded-lg transition-colors"
|
|
305
|
+
>
|
|
306
|
+
Try again
|
|
307
|
+
</button>
|
|
308
|
+
)}
|
|
309
|
+
</>
|
|
310
|
+
) : mode === 'metered' || !hasCard || isPaymentMethodRequired || isPaymentDeclined ? (
|
|
311
|
+
// Metered, or no card on file → capture/update a card first.
|
|
312
|
+
<>
|
|
313
|
+
<button
|
|
314
|
+
onClick={addPaymentMethod}
|
|
315
|
+
disabled={busy}
|
|
316
|
+
className="w-full px-4 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-60 text-white rounded-lg transition-colors font-medium"
|
|
317
|
+
>
|
|
318
|
+
{busy ? 'Opening secure checkout…' : hasCard ? 'Update payment method' : 'Add a payment method'}
|
|
319
|
+
</button>
|
|
320
|
+
{mode === 'metered' && (
|
|
321
|
+
<p className="text-xs text-gray-500 text-center">
|
|
322
|
+
You’ll be billed automatically as you use the service.
|
|
323
|
+
</p>
|
|
324
|
+
)}
|
|
325
|
+
{onRetry && (
|
|
326
|
+
<button
|
|
327
|
+
onClick={onRetry}
|
|
328
|
+
className="w-full px-4 py-2 text-gray-400 hover:text-white text-sm transition-colors"
|
|
329
|
+
>
|
|
330
|
+
Try again
|
|
331
|
+
</button>
|
|
332
|
+
)}
|
|
333
|
+
</>
|
|
334
|
+
) : (
|
|
335
|
+
// Prepaid + card on file → 1-click add funds.
|
|
336
|
+
<>
|
|
337
|
+
<div className="grid grid-cols-3 gap-2">
|
|
338
|
+
{QUICK_AMOUNTS.map((usd) => (
|
|
339
|
+
<button
|
|
340
|
+
key={usd}
|
|
341
|
+
onClick={() => addFunds(usd)}
|
|
342
|
+
disabled={busy}
|
|
343
|
+
className="px-3 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-60 text-white rounded-lg transition-colors font-medium"
|
|
344
|
+
>
|
|
345
|
+
${usd}
|
|
346
|
+
</button>
|
|
347
|
+
))}
|
|
348
|
+
</div>
|
|
349
|
+
<button
|
|
350
|
+
onClick={() => openSettings(wallet?.settingsUrls.topup)}
|
|
351
|
+
className="w-full px-4 py-2 text-gray-400 hover:text-white text-sm transition-colors"
|
|
352
|
+
>
|
|
353
|
+
Other amount…
|
|
354
|
+
</button>
|
|
355
|
+
</>
|
|
356
|
+
)}
|
|
357
|
+
|
|
358
|
+
{!funded && (
|
|
359
|
+
<button
|
|
360
|
+
onClick={onClose}
|
|
361
|
+
className="w-full px-4 py-2 text-gray-500 hover:text-gray-300 text-sm transition-colors"
|
|
362
|
+
>
|
|
363
|
+
Close
|
|
364
|
+
</button>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
</>
|
|
368
|
+
)}
|
|
369
|
+
</div>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|