@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.
Files changed (102) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +391 -0
  3. package/dist/components/AuthButton.d.ts +51 -0
  4. package/dist/components/AuthButton.js +60 -0
  5. package/dist/components/AuthModal.d.ts +20 -0
  6. package/dist/components/AuthModal.js +62 -0
  7. package/dist/components/BuyButton.d.ts +47 -0
  8. package/dist/components/BuyButton.js +74 -0
  9. package/dist/components/ErrorBoundary.d.ts +13 -0
  10. package/dist/components/ErrorBoundary.js +24 -0
  11. package/dist/components/HyperyModals.d.ts +30 -0
  12. package/dist/components/HyperyModals.js +30 -0
  13. package/dist/components/InsufficientCreditsAlert.d.ts +11 -0
  14. package/dist/components/InsufficientCreditsAlert.js +12 -0
  15. package/dist/components/ModernAuthForm.d.ts +52 -0
  16. package/dist/components/ModernAuthForm.js +83 -0
  17. package/dist/components/RestrictionModal.d.ts +43 -0
  18. package/dist/components/RestrictionModal.js +167 -0
  19. package/dist/components/SignIn.d.ts +26 -0
  20. package/dist/components/SignIn.js +47 -0
  21. package/dist/components/SignInForm.d.ts +41 -0
  22. package/dist/components/SignInForm.js +86 -0
  23. package/dist/components/SignUp.d.ts +26 -0
  24. package/dist/components/SignUp.js +52 -0
  25. package/dist/components/SpendingLimitAlert.d.ts +12 -0
  26. package/dist/components/SpendingLimitAlert.js +14 -0
  27. package/dist/components/UserButton.d.ts +26 -0
  28. package/dist/components/UserButton.js +35 -0
  29. package/dist/components/UserProfile.d.ts +22 -0
  30. package/dist/components/UserProfile.js +26 -0
  31. package/dist/components/WorkspaceSwitcher.d.ts +29 -0
  32. package/dist/components/WorkspaceSwitcher.js +66 -0
  33. package/dist/components/control.d.ts +64 -0
  34. package/dist/components/control.js +89 -0
  35. package/dist/components/ui/dialog.d.ts +19 -0
  36. package/dist/components/ui/dialog.js +53 -0
  37. package/dist/hooks/index.d.ts +22 -0
  38. package/dist/hooks/index.js +23 -0
  39. package/dist/hooks/useBuyerWallet.d.ts +37 -0
  40. package/dist/hooks/useBuyerWallet.js +66 -0
  41. package/dist/hooks/useCheckout.d.ts +27 -0
  42. package/dist/hooks/useCheckout.js +246 -0
  43. package/dist/hooks/useError.d.ts +19 -0
  44. package/dist/hooks/useError.js +36 -0
  45. package/dist/hooks/useMarketplace.d.ts +50 -0
  46. package/dist/hooks/useMarketplace.js +69 -0
  47. package/dist/hooks/useMemberships.d.ts +71 -0
  48. package/dist/hooks/useMemberships.js +138 -0
  49. package/dist/hooks/useWallet.d.ts +62 -0
  50. package/dist/hooks/useWallet.js +123 -0
  51. package/dist/index.d.ts +55 -0
  52. package/dist/index.js +52 -0
  53. package/dist/lib/context.d.ts +50 -0
  54. package/dist/lib/context.js +409 -0
  55. package/dist/lib/oauth.d.ts +49 -0
  56. package/dist/lib/oauth.js +149 -0
  57. package/dist/lib/parse-error.d.ts +45 -0
  58. package/dist/lib/parse-error.js +185 -0
  59. package/dist/lib/parse-stream.d.ts +43 -0
  60. package/dist/lib/parse-stream.js +89 -0
  61. package/dist/lib/popup.d.ts +42 -0
  62. package/dist/lib/popup.js +80 -0
  63. package/dist/lib/storage.d.ts +43 -0
  64. package/dist/lib/storage.js +125 -0
  65. package/dist/lib/utils.d.ts +8 -0
  66. package/dist/lib/utils.js +11 -0
  67. package/dist/types/index.d.ts +191 -0
  68. package/dist/types/index.js +4 -0
  69. package/package.json +78 -0
  70. package/src/components/AuthButton.tsx +123 -0
  71. package/src/components/AuthModal.tsx +240 -0
  72. package/src/components/BuyButton.tsx +150 -0
  73. package/src/components/ErrorBoundary.tsx +97 -0
  74. package/src/components/HyperyModals.tsx +83 -0
  75. package/src/components/InsufficientCreditsAlert.tsx +73 -0
  76. package/src/components/ModernAuthForm.tsx +322 -0
  77. package/src/components/RestrictionModal.tsx +373 -0
  78. package/src/components/SignIn.tsx +84 -0
  79. package/src/components/SignInForm.tsx +256 -0
  80. package/src/components/SignUp.tsx +86 -0
  81. package/src/components/SpendingLimitAlert.tsx +91 -0
  82. package/src/components/UserButton.tsx +106 -0
  83. package/src/components/UserProfile.tsx +106 -0
  84. package/src/components/WorkspaceSwitcher.tsx +199 -0
  85. package/src/components/control.tsx +133 -0
  86. package/src/components/ui/dialog.tsx +151 -0
  87. package/src/hooks/index.ts +65 -0
  88. package/src/hooks/useBuyerWallet.ts +117 -0
  89. package/src/hooks/useCheckout.ts +312 -0
  90. package/src/hooks/useError.ts +60 -0
  91. package/src/hooks/useMarketplace.ts +129 -0
  92. package/src/hooks/useMemberships.ts +203 -0
  93. package/src/hooks/useWallet.ts +170 -0
  94. package/src/index.ts +147 -0
  95. package/src/lib/context.tsx +478 -0
  96. package/src/lib/oauth.ts +215 -0
  97. package/src/lib/parse-error.ts +224 -0
  98. package/src/lib/parse-stream.ts +121 -0
  99. package/src/lib/popup.ts +98 -0
  100. package/src/lib/storage.ts +140 -0
  101. package/src/lib/utils.ts +14 -0
  102. 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
+ }