cosmic-payments
Version:
A payments library for cosmic.new. Designed to be used and deployed on cosmic.new
280 lines (279 loc) • 10.7 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.useCosmicPayments = useCosmicPayments;
const react_1 = require("react");
/**
* Global singleton API client that maintains state across all hook instances.
*/
class CosmicPaymentsClient {
constructor() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.activeRequests = new Map();
this.subscribers = new Set();
this.currentState = { loading: false, error: null };
this.productCache = null;
this.cacheTimestamp = 0;
this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
// Add caching for access checks
this.accessCache = new Map();
this.purchaseHistoryCache = null;
this.subscriptionsCache = null;
this.ACCESS_CACHE_DURATION = 60 * 1000; // 1 minute for access checks
/**
* Get all products with optional filtering
*/
this.getProducts = async (filter = 'all') => {
// Check cache first
const now = Date.now();
if (this.productCache && (now - this.cacheTimestamp) < this.CACHE_DURATION) {
return this.filterProducts(this.productCache, filter);
}
// Fetch both types of products in parallel
const [subResult, nonSubResult] = await Promise.all([
this.makeApiCall('get-all-subscription-products'),
this.makeApiCall('get-all-non-subscription-products')
]);
if (!subResult || !nonSubResult)
return null;
// Combine and mark products
const allProducts = [
...(subResult.products || []).map(p => ({ ...p, is_subscription: true })),
...(nonSubResult.products || []).map(p => ({ ...p, is_subscription: false }))
];
// Update cache
this.productCache = allProducts;
this.cacheTimestamp = now;
return this.filterProducts(allProducts, filter);
};
/**
* Check if user has access to a product
*/
this.hasAccess = async (productId) => {
// Check cache first
const cached = this.accessCache.get(productId);
const now = Date.now();
if (cached && (now - cached.timestamp) < this.ACCESS_CACHE_DURATION) {
return cached.result;
}
const [history, subscriptions] = await Promise.all([
this.getPurchaseHistory(),
this.getActiveSubscriptions()
]);
// Check one-time purchases
const hasPurchased = history?.some(item => item.product_id === productId) || false;
// Check active subscriptions
const hasActiveSubscription = subscriptions?.some(sub => sub.subscription_product_id === productId && sub.subscription_status === 'active') || false;
const result = hasPurchased || hasActiveSubscription;
// Cache the result
this.accessCache.set(productId, { result, timestamp: now });
return result;
};
/**
* Get purchase history for authenticated users
*/
this.getPurchaseHistory = async () => {
// Check cache first
const now = Date.now();
if (this.purchaseHistoryCache && (now - this.purchaseHistoryCache.timestamp) < this.ACCESS_CACHE_DURATION) {
return this.purchaseHistoryCache.data;
}
const result = await this.makeApiCall('get-purchase-history');
const data = result?.purchaseHistory || null;
// Cache the result
this.purchaseHistoryCache = { data, timestamp: now };
return data;
};
/**
* Get active subscriptions for authenticated users
*/
this.getActiveSubscriptions = async () => {
// Check cache first
const now = Date.now();
if (this.subscriptionsCache && (now - this.subscriptionsCache.timestamp) < this.ACCESS_CACHE_DURATION) {
return this.subscriptionsCache.data;
}
const result = await this.makeApiCall('get-active-subscriptions');
const data = result?.activeSubscriptions || null;
// Cache the result
this.subscriptionsCache = { data, timestamp: now };
return data;
};
/**
* Create checkout and redirect immediately
*/
this.checkout = async (params) => {
const result = await this.makeApiCall('create-checkout-link', params);
if (result?.url) {
// Clear caches since user might be making a purchase
this.clearCaches();
window.location.href = result.url;
}
};
/**
* Open billing portal for subscription management
*/
this.openBillingPortal = async () => {
const result = await this.makeApiCall('get-billing-portal');
if (result?.url) {
// Clear caches since user might be managing subscriptions
this.clearCaches();
window.location.href = result.url;
}
};
}
/**
* Notify all subscribers of state changes
*/
notifySubscribers() {
this.subscribers.forEach(callback => callback(this.currentState));
}
/**
* Update loading state based on active requests
*/
updateLoading() {
const newLoading = this.activeRequests.size > 0;
if (newLoading !== this.currentState.loading) {
this.currentState.loading = newLoading;
this.notifySubscribers();
}
}
/**
* Set error state and notify subscribers
*/
setError(error) {
if (error !== this.currentState.error) {
this.currentState.error = error;
this.notifySubscribers();
}
}
/**
* Subscribe to state changes
*/
subscribe(callback) {
this.subscribers.add(callback);
callback(this.currentState);
return () => {
this.subscribers.delete(callback);
};
}
/**
* Clear all caches (useful after purchases or subscription changes)
*/
clearCaches() {
this.accessCache.clear();
this.purchaseHistoryCache = null;
this.subscriptionsCache = null;
this.productCache = null;
}
/**
* Make an API call with request deduplication and state management
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async makeApiCall(actionType, additionalData) {
const requestKey = `${actionType}-${JSON.stringify(additionalData || {})}`;
// Check if there's already an active request for this exact call
const existingRequest = this.activeRequests.get(requestKey);
if (existingRequest) {
// Return the existing promise instead of null
return existingRequest;
}
// Create the promise for this request
const requestPromise = (async () => {
this.updateLoading();
this.setError(null);
try {
const response = await fetch('/api/cosmic-payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actionType,
...additionalData,
}),
credentials: 'include',
});
if (!response.ok) {
let errorMessage = `HTTP error! Status: ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
}
catch {
// If we can't parse the error response, use the default message
}
throw new Error(errorMessage);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
return data;
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred';
console.error(`[useCosmicPayments] Error with ${actionType}:`, err);
this.setError(errorMessage);
return null;
}
finally {
// Clean up the request from the map
this.activeRequests.delete(requestKey);
this.updateLoading();
}
})();
// Store the promise
this.activeRequests.set(requestKey, requestPromise);
return requestPromise;
}
filterProducts(products, filter) {
switch (filter) {
case 'subscription':
return products.filter(p => p.is_subscription);
case 'one-time':
return products.filter(p => !p.is_subscription);
default:
return products;
}
}
}
// Global singleton instance
const cosmicPaymentsClient = new CosmicPaymentsClient();
/**
* Simple hook for Cosmic Payments integration
*
* @example
* ```tsx
* const { getProducts, checkout, hasAccess, loading, error } = useCosmicPayments();
*
* // Get products
* const products = await getProducts('subscription'); // or 'one-time' or 'all'
*
* // Start checkout (with optional guest checkout)
* await checkout({
* priceId: price.price_id,
* productId: product.product_id,
* allowGuestCheckout: true // Optional, defaults to false
* });
*
* // Check access
* const hasAccess = await hasAccess('prod_123');
* ```
*/
function useCosmicPayments() {
const [state, setState] = (0, react_1.useState)({ loading: false, error: null });
(0, react_1.useEffect)(() => {
const unsubscribe = cosmicPaymentsClient.subscribe(setState);
return unsubscribe;
}, []);
return {
getProducts: cosmicPaymentsClient.getProducts,
checkout: cosmicPaymentsClient.checkout,
openBillingPortal: cosmicPaymentsClient.openBillingPortal,
hasAccess: cosmicPaymentsClient.hasAccess,
getPurchaseHistory: cosmicPaymentsClient.getPurchaseHistory,
getActiveSubscriptions: cosmicPaymentsClient.getActiveSubscriptions,
loading: state.loading,
error: state.error,
};
}