@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,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { X } from "lucide-react";
|
|
6
|
+
import { cn } from "../../lib/utils";
|
|
7
|
+
const Dialog = DialogPrimitive.Root;
|
|
8
|
+
const DialogTrigger = DialogPrimitive.Trigger;
|
|
9
|
+
const DialogPortal = DialogPrimitive.Portal;
|
|
10
|
+
const DialogClose = DialogPrimitive.Close;
|
|
11
|
+
const DialogOverlay = React.forwardRef(({ className, style, ...props }, ref) => (_jsx(DialogPrimitive.Overlay, { ref: ref, style: {
|
|
12
|
+
position: 'fixed',
|
|
13
|
+
inset: 0,
|
|
14
|
+
zIndex: 50,
|
|
15
|
+
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
|
16
|
+
...style,
|
|
17
|
+
}, className: className, ...props })));
|
|
18
|
+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
|
19
|
+
const DialogContent = React.forwardRef(({ className, children, style, ...props }, ref) => (_jsxs(DialogPortal, { children: [_jsx(DialogOverlay, {}), _jsxs(DialogPrimitive.Content, { ref: ref, style: {
|
|
20
|
+
position: 'fixed',
|
|
21
|
+
left: '50%',
|
|
22
|
+
top: '50%',
|
|
23
|
+
zIndex: 50,
|
|
24
|
+
display: 'grid',
|
|
25
|
+
width: '100%',
|
|
26
|
+
maxWidth: '32rem',
|
|
27
|
+
transform: 'translate(-50%, -50%)',
|
|
28
|
+
gap: '1rem',
|
|
29
|
+
border: '1px solid #e5e7eb',
|
|
30
|
+
backgroundColor: '#ffffff',
|
|
31
|
+
padding: '1.5rem',
|
|
32
|
+
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
|
33
|
+
borderRadius: '0.5rem',
|
|
34
|
+
...style,
|
|
35
|
+
}, className: className, ...props, children: [children, _jsxs(DialogPrimitive.Close, { style: {
|
|
36
|
+
position: 'absolute',
|
|
37
|
+
right: '1rem',
|
|
38
|
+
top: '1rem',
|
|
39
|
+
opacity: 0.7,
|
|
40
|
+
cursor: 'pointer',
|
|
41
|
+
border: 'none',
|
|
42
|
+
background: 'transparent',
|
|
43
|
+
}, children: [_jsx(X, { style: { width: '1rem', height: '1rem' } }), _jsx("span", { style: { position: 'absolute', width: '1px', height: '1px', padding: 0, margin: '-1px', overflow: 'hidden', clip: 'rect(0, 0, 0, 0)', whiteSpace: 'nowrap', borderWidth: 0 }, children: "Close" })] })] })] })));
|
|
44
|
+
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
|
45
|
+
const DialogHeader = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col space-y-1.5 text-center sm:text-left", className), ...props }));
|
|
46
|
+
DialogHeader.displayName = "DialogHeader";
|
|
47
|
+
const DialogFooter = ({ className, ...props }) => (_jsx("div", { className: cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className), ...props }));
|
|
48
|
+
DialogFooter.displayName = "DialogFooter";
|
|
49
|
+
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (_jsx(DialogPrimitive.Title, { ref: ref, className: cn("text-lg font-semibold leading-none tracking-tight", className), ...props })));
|
|
50
|
+
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
|
51
|
+
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (_jsx(DialogPrimitive.Description, { ref: ref, className: cn("text-sm text-muted-foreground", className), ...props })));
|
|
52
|
+
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
|
53
|
+
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription, };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export hooks for convenience
|
|
3
|
+
*/
|
|
4
|
+
export { useHyperyAuth, useUser } from '../lib/context';
|
|
5
|
+
/**
|
|
6
|
+
* Alias for useHyperyAuth to match Clerk's API
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const { isLoaded, isAuthenticated, user } = useAuth();
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export { useHyperyAuth as useAuth } from '../lib/context';
|
|
13
|
+
export { useMemberships, useActiveWorkspace, setActiveWorkspace, } from './useMemberships';
|
|
14
|
+
export type { MembershipEntry, MembershipTeam, MembershipWorkspace, MembershipsResponse, ActiveWorkspace, } from './useMemberships';
|
|
15
|
+
export { useWallet } from './useWallet';
|
|
16
|
+
export type { BillingMode, WalletState, WalletTier, UseWalletReturn, } from './useWallet';
|
|
17
|
+
export { useBuyerWallet } from './useBuyerWallet';
|
|
18
|
+
export type { BuyerPaymentMethod, AddCardOptions, UseBuyerWalletReturn, } from './useBuyerWallet';
|
|
19
|
+
export { useMarketplace } from './useMarketplace';
|
|
20
|
+
export type { BuyInput, BuySuccess, BuyFailure, BuyResult, UseMarketplaceReturn, } from './useMarketplace';
|
|
21
|
+
export { useCheckout } from './useCheckout';
|
|
22
|
+
export type { CheckoutInput, CheckoutStatus, CheckoutResult, UseCheckoutReturn, } from './useCheckout';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-export hooks for convenience
|
|
3
|
+
*/
|
|
4
|
+
export { useHyperyAuth, useUser } from '../lib/context';
|
|
5
|
+
/**
|
|
6
|
+
* Alias for useHyperyAuth to match Clerk's API
|
|
7
|
+
* @example
|
|
8
|
+
* ```tsx
|
|
9
|
+
* const { isLoaded, isAuthenticated, user } = useAuth();
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
export { useHyperyAuth as useAuth } from '../lib/context';
|
|
13
|
+
// Workspace + membership hooks for the WorkspaceSwitcher and consumer apps.
|
|
14
|
+
export { useMemberships, useActiveWorkspace, setActiveWorkspace, } from './useMemberships';
|
|
15
|
+
// Mode-aware wallet hook (balance, 1-click add funds, add payment method).
|
|
16
|
+
export { useWallet } from './useWallet';
|
|
17
|
+
// "Buy with Hypery" marketplace layer (Stripe Connect, consumer side).
|
|
18
|
+
export { useBuyerWallet } from './useBuyerWallet';
|
|
19
|
+
export { useMarketplace } from './useMarketplace';
|
|
20
|
+
// Seamless auth + charge orchestrator (login → charge → add-card → retry) in
|
|
21
|
+
// popup or redirect mode. Powers <BuyButton>; use directly for custom buttons
|
|
22
|
+
// and for AI-credit top-ups (kind: 'topup').
|
|
23
|
+
export { useCheckout } from './useCheckout';
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer wallet hook for the "buy with Hypery" marketplace layer.
|
|
3
|
+
*
|
|
4
|
+
* Reads the buyer's saved payment methods from the gateway
|
|
5
|
+
* (GET /api/buyer/wallet) and exposes an `addCard` action that starts the
|
|
6
|
+
* Stripe-hosted card-entry flow (POST /api/buyer/wallet/checkout-setup) and
|
|
7
|
+
* redirects the browser to it.
|
|
8
|
+
*/
|
|
9
|
+
import type { ParsedError } from "../types";
|
|
10
|
+
export interface BuyerPaymentMethod {
|
|
11
|
+
id: string;
|
|
12
|
+
brand: string;
|
|
13
|
+
last4: string;
|
|
14
|
+
expMonth: number;
|
|
15
|
+
expYear: number;
|
|
16
|
+
isDefault: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface AddCardOptions {
|
|
19
|
+
/** Where Stripe returns after a successful card add. Defaults to current URL. */
|
|
20
|
+
successUrl?: string;
|
|
21
|
+
/** Where Stripe returns if the buyer cancels. Defaults to current URL. */
|
|
22
|
+
cancelUrl?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface UseBuyerWalletReturn {
|
|
25
|
+
paymentMethods: BuyerPaymentMethod[];
|
|
26
|
+
hasDefault: boolean;
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
error: ParsedError | null;
|
|
29
|
+
/** Re-fetch the buyer wallet snapshot. */
|
|
30
|
+
refresh: () => Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Start Stripe-hosted card entry and redirect the browser to it.
|
|
33
|
+
* `successUrl`/`cancelUrl` default to the current location.
|
|
34
|
+
*/
|
|
35
|
+
addCard: (opts?: AddCardOptions) => Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
export declare function useBuyerWallet(): UseBuyerWalletReturn;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Buyer wallet hook for the "buy with Hypery" marketplace layer.
|
|
3
|
+
*
|
|
4
|
+
* Reads the buyer's saved payment methods from the gateway
|
|
5
|
+
* (GET /api/buyer/wallet) and exposes an `addCard` action that starts the
|
|
6
|
+
* Stripe-hosted card-entry flow (POST /api/buyer/wallet/checkout-setup) and
|
|
7
|
+
* redirects the browser to it.
|
|
8
|
+
*/
|
|
9
|
+
"use client";
|
|
10
|
+
import { useCallback, useEffect, useState } from "react";
|
|
11
|
+
import { useHyperyAuth } from "../lib/context";
|
|
12
|
+
import { parseError } from "../lib/parse-error";
|
|
13
|
+
function currentLocation() {
|
|
14
|
+
return typeof window !== "undefined" ? window.location.href : "";
|
|
15
|
+
}
|
|
16
|
+
export function useBuyerWallet() {
|
|
17
|
+
const { authenticatedFetch, gatewayUrl } = useHyperyAuth();
|
|
18
|
+
const [paymentMethods, setPaymentMethods] = useState([]);
|
|
19
|
+
const [hasDefault, setHasDefault] = useState(false);
|
|
20
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const refresh = useCallback(async () => {
|
|
23
|
+
setIsLoading(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
const res = await authenticatedFetch(`${gatewayUrl}/api/buyer/wallet`);
|
|
27
|
+
const body = await res.json().catch(() => ({}));
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
setError(parseError({ ...body, status: res.status }));
|
|
30
|
+
setPaymentMethods([]);
|
|
31
|
+
setHasDefault(false);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setPaymentMethods(Array.isArray(body?.paymentMethods) ? body.paymentMethods : []);
|
|
35
|
+
setHasDefault(Boolean(body?.hasDefault));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
setError(parseError(err));
|
|
39
|
+
setPaymentMethods([]);
|
|
40
|
+
setHasDefault(false);
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
setIsLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}, [authenticatedFetch, gatewayUrl]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
void refresh();
|
|
48
|
+
}, [refresh]);
|
|
49
|
+
const addCard = useCallback(async (opts = {}) => {
|
|
50
|
+
const successUrl = opts.successUrl ?? currentLocation();
|
|
51
|
+
const cancelUrl = opts.cancelUrl ?? currentLocation();
|
|
52
|
+
const res = await authenticatedFetch(`${gatewayUrl}/api/buyer/wallet/checkout-setup`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: { "Content-Type": "application/json" },
|
|
55
|
+
body: JSON.stringify({ successUrl, cancelUrl }),
|
|
56
|
+
});
|
|
57
|
+
const body = await res.json().catch(() => ({}));
|
|
58
|
+
if (!res.ok || !body?.url) {
|
|
59
|
+
throw new Error(body?.error?.message || `Could not start card entry: ${res.status}`);
|
|
60
|
+
}
|
|
61
|
+
if (typeof window !== "undefined") {
|
|
62
|
+
window.location.href = body.url;
|
|
63
|
+
}
|
|
64
|
+
}, [authenticatedFetch, gatewayUrl]);
|
|
65
|
+
return { paymentMethods, hasDefault, isLoading, error, refresh, addCard };
|
|
66
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ParsedError } from '../types';
|
|
2
|
+
export type CheckoutInput = {
|
|
3
|
+
kind: 'purchase';
|
|
4
|
+
appId: string;
|
|
5
|
+
amountCents: number;
|
|
6
|
+
description?: string;
|
|
7
|
+
/** Stable key so a resumed/retried charge is not double-billed. Auto-generated if omitted. */
|
|
8
|
+
idempotencyKey?: string;
|
|
9
|
+
} | {
|
|
10
|
+
kind: 'topup';
|
|
11
|
+
/** USD amount of credits to buy. */
|
|
12
|
+
usdAmount: number;
|
|
13
|
+
};
|
|
14
|
+
export type CheckoutStatus = 'idle' | 'authenticating' | 'charging' | 'adding-card' | 'redirecting' | 'success' | 'error' | 'cancelled';
|
|
15
|
+
export interface CheckoutResult {
|
|
16
|
+
status: 'success' | 'error' | 'cancelled' | 'redirecting';
|
|
17
|
+
data?: any;
|
|
18
|
+
error?: ParsedError;
|
|
19
|
+
}
|
|
20
|
+
export interface UseCheckoutReturn {
|
|
21
|
+
/** Run the auth+charge flow. Resolves when the chain finishes (or is redirecting away). */
|
|
22
|
+
checkout: (input: CheckoutInput) => Promise<CheckoutResult>;
|
|
23
|
+
status: CheckoutStatus;
|
|
24
|
+
isRunning: boolean;
|
|
25
|
+
error: ParsedError | null;
|
|
26
|
+
}
|
|
27
|
+
export declare function useCheckout(): UseCheckoutReturn;
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
/**
|
|
3
|
+
* useCheckout — the seamless auth + charge flow.
|
|
4
|
+
*
|
|
5
|
+
* One action drives the whole chain: press-buy → (log in if needed) → charge →
|
|
6
|
+
* (add a card if needed) → retry → done. Works two ways, chosen by
|
|
7
|
+
* `config.interactionMode` (`auto` by default):
|
|
8
|
+
*
|
|
9
|
+
* - popup: each interactive step is a centered popup; the chain completes
|
|
10
|
+
* in-page without navigation (PayPal / Firebase signInWithPopup).
|
|
11
|
+
* - redirect: each step is a full-page redirect; the flow is persisted and
|
|
12
|
+
* auto-resumed when the user returns (robust on mobile / blocked
|
|
13
|
+
* popups). `auto` falls back to redirect there automatically.
|
|
14
|
+
*
|
|
15
|
+
* Covers two charge kinds:
|
|
16
|
+
* - `purchase` — a marketplace Connect charge (POST /api/marketplace/checkout).
|
|
17
|
+
* - `topup` — buy AI credits (POST /api/wallet/topup).
|
|
18
|
+
*/
|
|
19
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
20
|
+
import { useHyperyAuth } from '../lib/context';
|
|
21
|
+
import { openPopup } from '../lib/popup';
|
|
22
|
+
import { parseError } from '../lib/parse-error';
|
|
23
|
+
const PENDING_KEY = 'hypery_pending_checkout';
|
|
24
|
+
const MAX_ATTEMPTS = 3; // auth redirect + card redirect + margin; guards against loops
|
|
25
|
+
const CARD_POPUP_NAME = 'hypery-add-card';
|
|
26
|
+
const ENDPOINTS = {
|
|
27
|
+
purchase: { charge: '/api/marketplace/checkout', cardSetup: '/api/buyer/wallet/checkout-setup' },
|
|
28
|
+
topup: { charge: '/api/wallet/topup', cardSetup: '/api/payments/stripe/checkout-setup' },
|
|
29
|
+
};
|
|
30
|
+
// Module-level guard so a redirect-resume runs exactly once per page load, even
|
|
31
|
+
// if several components mount useCheckout.
|
|
32
|
+
let resumeConsumed = false;
|
|
33
|
+
function readPending() {
|
|
34
|
+
if (typeof window === 'undefined')
|
|
35
|
+
return null;
|
|
36
|
+
try {
|
|
37
|
+
const raw = window.localStorage.getItem(PENDING_KEY);
|
|
38
|
+
return raw ? JSON.parse(raw) : null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function writePending(input, attempts) {
|
|
45
|
+
if (typeof window === 'undefined')
|
|
46
|
+
return;
|
|
47
|
+
try {
|
|
48
|
+
window.localStorage.setItem(PENDING_KEY, JSON.stringify({ input, attempts }));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* storage may be unavailable; redirect resume just won't fire */
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function clearPending() {
|
|
55
|
+
if (typeof window === 'undefined')
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
window.localStorage.removeItem(PENDING_KEY);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* ignore */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function currentUrl() {
|
|
65
|
+
return typeof window !== 'undefined' ? window.location.href : '';
|
|
66
|
+
}
|
|
67
|
+
function withIdempotency(input) {
|
|
68
|
+
if (input.kind === 'purchase' && !input.idempotencyKey) {
|
|
69
|
+
const key = typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
|
70
|
+
? crypto.randomUUID()
|
|
71
|
+
: `idem_${Date.now()}_${Math.floor(Math.random() * 1e9)}`;
|
|
72
|
+
return { ...input, idempotencyKey: key };
|
|
73
|
+
}
|
|
74
|
+
return input;
|
|
75
|
+
}
|
|
76
|
+
export function useCheckout() {
|
|
77
|
+
const { isAuthenticated, isLoading, loginPopup, login, interactionMode, gatewayUrl, getAccessToken } = useHyperyAuth();
|
|
78
|
+
const [status, setStatus] = useState('idle');
|
|
79
|
+
const [error, setError] = useState(null);
|
|
80
|
+
const runningRef = useRef(false);
|
|
81
|
+
const gatewayOrigin = (() => {
|
|
82
|
+
try {
|
|
83
|
+
return new URL(gatewayUrl).origin;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return typeof window !== 'undefined' ? window.location.origin : '';
|
|
87
|
+
}
|
|
88
|
+
})();
|
|
89
|
+
const authedFetch = useCallback(async (path, body) => {
|
|
90
|
+
const token = await getAccessToken();
|
|
91
|
+
const res = await fetch(`${gatewayUrl}${path}`, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers: {
|
|
94
|
+
'Content-Type': 'application/json',
|
|
95
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(body),
|
|
98
|
+
});
|
|
99
|
+
const parsed = await res.json().catch(() => ({}));
|
|
100
|
+
return { res, body: parsed };
|
|
101
|
+
}, [gatewayUrl, getAccessToken]);
|
|
102
|
+
const chargeBody = (input) => input.kind === 'purchase'
|
|
103
|
+
? {
|
|
104
|
+
appId: input.appId,
|
|
105
|
+
amountCents: input.amountCents,
|
|
106
|
+
description: input.description,
|
|
107
|
+
idempotencyKey: input.idempotencyKey,
|
|
108
|
+
}
|
|
109
|
+
: { amount: input.usdAmount };
|
|
110
|
+
/** Open the Stripe-hosted card-entry popup and wait for it to finish (or close). */
|
|
111
|
+
const addCardPopup = useCallback(async (input) => {
|
|
112
|
+
const { body } = await authedFetch(ENDPOINTS[input.kind].cardSetup, {
|
|
113
|
+
successUrl: currentUrl(),
|
|
114
|
+
cancelUrl: currentUrl(),
|
|
115
|
+
});
|
|
116
|
+
if (!body?.url)
|
|
117
|
+
return false;
|
|
118
|
+
const result = await openPopup({
|
|
119
|
+
url: body.url,
|
|
120
|
+
name: CARD_POPUP_NAME,
|
|
121
|
+
expectedOrigin: gatewayOrigin,
|
|
122
|
+
messageType: 'hypery:payment-method',
|
|
123
|
+
});
|
|
124
|
+
if (result.blocked)
|
|
125
|
+
return false; // caller falls back to redirect
|
|
126
|
+
// Whether we got an explicit "added" message or the popup just closed, the
|
|
127
|
+
// retry charge below is the source of truth (a 402 again ⇒ still no card).
|
|
128
|
+
return !result.cancelled;
|
|
129
|
+
}, [authedFetch, gatewayOrigin]);
|
|
130
|
+
/** Redirect to Stripe-hosted card entry, persisting the flow to resume on return. */
|
|
131
|
+
const addCardRedirect = useCallback(async (input, attempts) => {
|
|
132
|
+
const { body } = await authedFetch(ENDPOINTS[input.kind].cardSetup, {
|
|
133
|
+
successUrl: currentUrl(),
|
|
134
|
+
cancelUrl: currentUrl(),
|
|
135
|
+
});
|
|
136
|
+
if (body?.url && typeof window !== 'undefined') {
|
|
137
|
+
writePending(input, attempts + 1);
|
|
138
|
+
window.location.href = body.url;
|
|
139
|
+
}
|
|
140
|
+
}, [authedFetch]);
|
|
141
|
+
const runCheckout = useCallback(async (rawInput, attempts) => {
|
|
142
|
+
if (runningRef.current)
|
|
143
|
+
return { status: 'error', error: undefined };
|
|
144
|
+
runningRef.current = true;
|
|
145
|
+
setError(null);
|
|
146
|
+
const input = withIdempotency(rawInput);
|
|
147
|
+
try {
|
|
148
|
+
// 1) Ensure authentication.
|
|
149
|
+
if (!isAuthenticated) {
|
|
150
|
+
setStatus('authenticating');
|
|
151
|
+
if (interactionMode === 'popup') {
|
|
152
|
+
const r = await loginPopup();
|
|
153
|
+
if (r.cancelled) {
|
|
154
|
+
setStatus('cancelled');
|
|
155
|
+
return { status: 'cancelled' };
|
|
156
|
+
}
|
|
157
|
+
if (r.blocked) {
|
|
158
|
+
// Popup blocked → fall back to a full-page redirect + resume.
|
|
159
|
+
setStatus('redirecting');
|
|
160
|
+
writePending(input, attempts + 1);
|
|
161
|
+
await login();
|
|
162
|
+
return { status: 'redirecting' };
|
|
163
|
+
}
|
|
164
|
+
// r.ok → fall through to charge (isAuthenticated updates async, but the
|
|
165
|
+
// token is now in storage so getAccessToken() will return it).
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
setStatus('redirecting');
|
|
169
|
+
writePending(input, attempts + 1);
|
|
170
|
+
await login();
|
|
171
|
+
return { status: 'redirecting' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// 2) Attempt the charge. Retry once after adding a card.
|
|
175
|
+
for (let charge = 0; charge < 2; charge++) {
|
|
176
|
+
setStatus('charging');
|
|
177
|
+
const { res, body } = await authedFetch(ENDPOINTS[input.kind].charge, chargeBody(input));
|
|
178
|
+
if (res.ok && body?.success !== false) {
|
|
179
|
+
clearPending();
|
|
180
|
+
setStatus('success');
|
|
181
|
+
return { status: 'success', data: body };
|
|
182
|
+
}
|
|
183
|
+
const parsed = parseError({ ...body, status: res.status });
|
|
184
|
+
// Needs a card → add one, then retry the charge once.
|
|
185
|
+
if ((parsed.isPaymentMethodRequired || res.status === 402) && charge === 0) {
|
|
186
|
+
setStatus('adding-card');
|
|
187
|
+
if (interactionMode === 'popup') {
|
|
188
|
+
const ok = await addCardPopup(input);
|
|
189
|
+
if (!ok) {
|
|
190
|
+
// Popup blocked/cancelled → redirect fallback (resumes on return).
|
|
191
|
+
setStatus('redirecting');
|
|
192
|
+
await addCardRedirect(input, attempts);
|
|
193
|
+
return { status: 'redirecting' };
|
|
194
|
+
}
|
|
195
|
+
continue; // retry charge
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
setStatus('redirecting');
|
|
199
|
+
await addCardRedirect(input, attempts);
|
|
200
|
+
return { status: 'redirecting' };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// Any other error is terminal for this flow.
|
|
204
|
+
clearPending();
|
|
205
|
+
setError(parsed);
|
|
206
|
+
setStatus('error');
|
|
207
|
+
return { status: 'error', error: parsed };
|
|
208
|
+
}
|
|
209
|
+
// Retried once after adding a card and still not ok.
|
|
210
|
+
const fail = parseError({ code: 'PAYMENT_METHOD_REQUIRED', status: 402 });
|
|
211
|
+
clearPending();
|
|
212
|
+
setError(fail);
|
|
213
|
+
setStatus('error');
|
|
214
|
+
return { status: 'error', error: fail };
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
const parsed = parseError(err);
|
|
218
|
+
clearPending();
|
|
219
|
+
setError(parsed);
|
|
220
|
+
setStatus('error');
|
|
221
|
+
return { status: 'error', error: parsed };
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
runningRef.current = false;
|
|
225
|
+
}
|
|
226
|
+
}, [isAuthenticated, interactionMode, loginPopup, login, authedFetch, addCardPopup, addCardRedirect]);
|
|
227
|
+
const checkout = useCallback((input) => runCheckout(input, 0), [runCheckout]);
|
|
228
|
+
// Redirect-mode resume: after the user returns from an auth or card redirect,
|
|
229
|
+
// continue the persisted flow exactly once.
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (resumeConsumed || isLoading)
|
|
232
|
+
return;
|
|
233
|
+
const pending = readPending();
|
|
234
|
+
if (!pending)
|
|
235
|
+
return;
|
|
236
|
+
if (!isAuthenticated)
|
|
237
|
+
return; // wait until the auth callback has set the user
|
|
238
|
+
if (pending.attempts >= MAX_ATTEMPTS) {
|
|
239
|
+
clearPending();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resumeConsumed = true;
|
|
243
|
+
void runCheckout(pending.input, pending.attempts);
|
|
244
|
+
}, [isAuthenticated, isLoading, runCheckout]);
|
|
245
|
+
return { checkout, status, isRunning: status !== 'idle' && status !== 'success' && status !== 'error' && status !== 'cancelled', error };
|
|
246
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ParsedError } from '../types';
|
|
2
|
+
export interface UseErrorReturn {
|
|
3
|
+
error: ParsedError | null;
|
|
4
|
+
setError: (error: any) => void;
|
|
5
|
+
clearError: () => void;
|
|
6
|
+
hasError: boolean;
|
|
7
|
+
isSpendingLimit: boolean;
|
|
8
|
+
isInsufficientCredits: boolean;
|
|
9
|
+
isPaymentMethodRequired: boolean;
|
|
10
|
+
isPaymentDeclined: boolean;
|
|
11
|
+
/** True when the error is an authentication failure (401) — open the auth modal / re-auth. */
|
|
12
|
+
isAuth: boolean;
|
|
13
|
+
/** True for any billing restriction the funds widget can resolve. */
|
|
14
|
+
isBillingRestriction: boolean;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Hook for managing error state with automatic parsing
|
|
18
|
+
*/
|
|
19
|
+
export declare function useError(): UseErrorReturn;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useState, useCallback } from 'react';
|
|
3
|
+
import { parseError } from '../lib/parse-error';
|
|
4
|
+
/**
|
|
5
|
+
* Hook for managing error state with automatic parsing
|
|
6
|
+
*/
|
|
7
|
+
export function useError() {
|
|
8
|
+
const [error, setErrorState] = useState(null);
|
|
9
|
+
const setError = useCallback((err) => {
|
|
10
|
+
if (!err) {
|
|
11
|
+
setErrorState(null);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
setErrorState(parseError(err));
|
|
15
|
+
}, []);
|
|
16
|
+
const clearError = useCallback(() => {
|
|
17
|
+
setErrorState(null);
|
|
18
|
+
}, []);
|
|
19
|
+
const isSpendingLimit = error?.isSpendingLimit || false;
|
|
20
|
+
const isInsufficientCredits = error?.isInsufficientCredits || false;
|
|
21
|
+
const isPaymentMethodRequired = error?.isPaymentMethodRequired || false;
|
|
22
|
+
const isPaymentDeclined = error?.isPaymentDeclined || false;
|
|
23
|
+
const isAuth = error?.isAuth || false;
|
|
24
|
+
return {
|
|
25
|
+
error,
|
|
26
|
+
setError,
|
|
27
|
+
clearError,
|
|
28
|
+
hasError: error !== null,
|
|
29
|
+
isSpendingLimit,
|
|
30
|
+
isInsufficientCredits,
|
|
31
|
+
isPaymentMethodRequired,
|
|
32
|
+
isPaymentDeclined,
|
|
33
|
+
isAuth,
|
|
34
|
+
isBillingRestriction: isSpendingLimit || isInsufficientCredits || isPaymentMethodRequired || isPaymentDeclined,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace hook for the "buy with Hypery" layer (Stripe Connect).
|
|
3
|
+
*
|
|
4
|
+
* Runs a marketplace checkout against the gateway
|
|
5
|
+
* (POST /api/marketplace/checkout). Expected billing errors (no card on file,
|
|
6
|
+
* card declined, seller not onboarded, ...) are returned as a discriminated
|
|
7
|
+
* result — this hook never throws for them.
|
|
8
|
+
*/
|
|
9
|
+
import type { ParsedError } from "../types";
|
|
10
|
+
export interface BuyInput {
|
|
11
|
+
/** Seller app the purchase is credited to. */
|
|
12
|
+
appId: string;
|
|
13
|
+
/** Amount to charge the buyer, in cents. */
|
|
14
|
+
amountCents: number;
|
|
15
|
+
/** Optional human-readable description of the purchase. */
|
|
16
|
+
description?: string;
|
|
17
|
+
/** Optional specific saved card (pm_...). Defaults to the buyer's default. */
|
|
18
|
+
paymentMethodId?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional idempotency key to make retries safe. Pass a STABLE value per
|
|
21
|
+
* logical purchase (e.g. an order id) so a user-initiated retry of the same
|
|
22
|
+
* purchase does not charge twice. If omitted, a fresh key is generated per
|
|
23
|
+
* call — which still protects against the SDK's internal auth-refresh retry,
|
|
24
|
+
* but not against a fresh second call for the same order.
|
|
25
|
+
*/
|
|
26
|
+
idempotencyKey?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface BuySuccess {
|
|
29
|
+
ok: true;
|
|
30
|
+
paymentIntentId: string;
|
|
31
|
+
status: string;
|
|
32
|
+
amountCents: number;
|
|
33
|
+
applicationFeeCents: number;
|
|
34
|
+
}
|
|
35
|
+
export interface BuyFailure {
|
|
36
|
+
ok: false;
|
|
37
|
+
error: ParsedError;
|
|
38
|
+
/** True when the buyer has no card and must add one before retrying. */
|
|
39
|
+
needsPaymentMethod: boolean;
|
|
40
|
+
/** True when the caller is not authenticated and must log in before retrying. */
|
|
41
|
+
needsAuth: boolean;
|
|
42
|
+
}
|
|
43
|
+
export type BuyResult = BuySuccess | BuyFailure;
|
|
44
|
+
export interface UseMarketplaceReturn {
|
|
45
|
+
/** Run a marketplace checkout. Never throws for expected billing errors. */
|
|
46
|
+
buy: (input: BuyInput) => Promise<BuyResult>;
|
|
47
|
+
isBuying: boolean;
|
|
48
|
+
lastError: ParsedError | null;
|
|
49
|
+
}
|
|
50
|
+
export declare function useMarketplace(): UseMarketplaceReturn;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace hook for the "buy with Hypery" layer (Stripe Connect).
|
|
3
|
+
*
|
|
4
|
+
* Runs a marketplace checkout against the gateway
|
|
5
|
+
* (POST /api/marketplace/checkout). Expected billing errors (no card on file,
|
|
6
|
+
* card declined, seller not onboarded, ...) are returned as a discriminated
|
|
7
|
+
* result — this hook never throws for them.
|
|
8
|
+
*/
|
|
9
|
+
"use client";
|
|
10
|
+
import { useCallback, useState } from "react";
|
|
11
|
+
import { useHyperyAuth } from "../lib/context";
|
|
12
|
+
import { isPaymentMethodRequiredError, parseError } from "../lib/parse-error";
|
|
13
|
+
/** Best-effort unique idempotency token (browser crypto with a fallback). */
|
|
14
|
+
function newIdempotencyKey() {
|
|
15
|
+
const c = globalThis.crypto;
|
|
16
|
+
if (c?.randomUUID)
|
|
17
|
+
return `mkt_${c.randomUUID()}`;
|
|
18
|
+
return `mkt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
|
|
19
|
+
}
|
|
20
|
+
export function useMarketplace() {
|
|
21
|
+
const { authenticatedFetch, gatewayUrl } = useHyperyAuth();
|
|
22
|
+
const [isBuying, setIsBuying] = useState(false);
|
|
23
|
+
const [lastError, setLastError] = useState(null);
|
|
24
|
+
const buy = useCallback(async (input) => {
|
|
25
|
+
setIsBuying(true);
|
|
26
|
+
setLastError(null);
|
|
27
|
+
const fail = (raw) => {
|
|
28
|
+
const error = parseError(raw);
|
|
29
|
+
setLastError(error);
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error,
|
|
33
|
+
needsPaymentMethod: isPaymentMethodRequiredError(raw),
|
|
34
|
+
needsAuth: error.isAuth,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
try {
|
|
38
|
+
const res = await authenticatedFetch(`${gatewayUrl}/api/marketplace/checkout`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "Content-Type": "application/json" },
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
appId: input.appId,
|
|
43
|
+
amountCents: input.amountCents,
|
|
44
|
+
description: input.description,
|
|
45
|
+
paymentMethodId: input.paymentMethodId,
|
|
46
|
+
idempotencyKey: input.idempotencyKey ?? newIdempotencyKey(),
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
const body = await res.json().catch(() => ({}));
|
|
50
|
+
if (!res.ok || !body?.success) {
|
|
51
|
+
return fail({ ...body, status: res.status });
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
paymentIntentId: body.paymentIntentId,
|
|
56
|
+
status: body.status,
|
|
57
|
+
amountCents: body.amountCents,
|
|
58
|
+
applicationFeeCents: body.applicationFeeCents,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return fail(err);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
setIsBuying(false);
|
|
66
|
+
}
|
|
67
|
+
}, [authenticatedFetch, gatewayUrl]);
|
|
68
|
+
return { buy, isBuying, lastError };
|
|
69
|
+
}
|