npm package: @ridwan-retainer/paywall
Description: Revenue-first monetization layer using RevenueCat for in-app purchases and subscriptions
Latest version: 0.1.2

# @ridwan-retainer/paywall

A complete, production-ready monetization solution for React Native apps using RevenueCat. Implements in-app purchases, subscriptions, paywalls, and entitlement management with a developer-friendly API.

[![npm version](https://badge.fury.io/js/@ridwan-retainer%2Fpaywall.svg)](https://www.npmjs.com/package/@ridwan-retainer/paywall)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

## 📋 Table of Contents

- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [Core Concepts](#core-concepts)
- [API Reference](#api-reference)
  - [Initialization](#initialization)
  - [Hooks](#hooks)
  - [Components](#components)
  - [Managers](#managers)
- [Complete Examples](#complete-examples)
- [Testing](#testing)
- [Best Practices](#best-practices)
- [Troubleshooting](#troubleshooting)

## ✨ Features

- **💰 RevenueCat Integration**: Full SDK integration with cross-platform support
- **🎨 Pre-built Components**: Ready-to-use paywall, purchase buttons, and subscription gates
- **🔐 Entitlement Management**: Automatic entitlement checking and caching
- **♻️ Restore Purchases**: Built-in restore functionality with error handling
- **📦 Offering Management**: Easy access to products and packages
- **🎯 Feature Gates**: Control feature access based on subscriptions
- **⚡ Performance**: Optimized caching and minimal re-renders
- **🪝 React Hooks**: Modern hooks-based API
- **📝 TypeScript**: Full type safety
- **✅ Well-tested**: Comprehensive test coverage

## 📦 Installation

```bash
npm install @ridwan-retainer/paywall
```

### Peer Dependencies

```bash
npm install react-native-purchases \
            react-native-purchases-ui \
            @react-native-async-storage/async-storage \
            react \
            react-native
```

### Platform Setup

#### iOS Setup

1. **Install CocoaPods dependencies:**
```bash
cd ios && pod install
```

2. **Configure App Store Connect:**
   - Create your app in App Store Connect
   - Set up in-app products and subscriptions
   - Submit for review

3. **Add StoreKit configuration** (for testing in Xcode):
   - File → New → File → StoreKit Configuration File
   - Add your products for local testing

#### Android Setup

1. **Configure Google Play Console:**
   - Create your app in Google Play Console
   - Set up in-app products and subscriptions
   - Add test users for testing

2. **Add billing permission** in `android/app/src/main/AndroidManifest.xml`:
```xml
<uses-permission android:name="com.android.vending.BILLING" />
```

### RevenueCat Setup

1. **Create RevenueCat account:** https://app.revenuecat.com/signup
2. **Create a project** and configure your app
3. **Link App Store/Google Play:**
   - iOS: Add App Store Connect API Key
   - Android: Add Google Play Service Account JSON
4. **Set up products:**
   - Create offerings
   - Add products to offerings
   - Configure entitlements
5. **Get API keys:**
   - Settings → API Keys
   - Copy iOS and Android keys

## 🚀 Quick Start

### 1. Configure Environment Variables

Create a `.env` file:

```env
EXPO_PUBLIC_REVENUECAT_IOS=your_ios_api_key
EXPO_PUBLIC_REVENUECAT_ANDROID=your_android_api_key
```

### 2. Initialize RevenueCat

```typescript
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';
import { useEffect } from 'react';

function App() {
  const { isInitialized, error } = useRevenueCatInitialization({
    appUserID: 'user_123', // Optional: your user ID
  });

  if (error) {
    console.error('RevenueCat initialization failed:', error);
  }

  return isInitialized ? <YourApp /> : <LoadingScreen />;
}
```

### 3. Display a Paywall

```typescript
import { Paywall } from '@ridwan-retainer/paywall';

function PaywallScreen() {
  return (
    <Paywall
      onPurchaseCompleted={({ customerInfo }) => {
        console.log('Purchase successful!', customerInfo);
        // Navigate away or show success message
      }}
      onDismiss={() => {
        console.log('Paywall dismissed');
      }}
      onPurchaseError={({ error }) => {
        console.error('Purchase failed:', error);
      }}
    />
  );
}
```

### 4. Gate Features

```typescript
import { SubscriptionGate } from '@ridwan-retainer/paywall';

function PremiumFeature() {
  return (
    <SubscriptionGate>
      <Text>This is premium content!</Text>
      <PremiumFeatureContent />
    </SubscriptionGate>
  );
}
```

## 🧠 Core Concepts

### Offerings and Packages

**Offerings** are collections of products (packages) you want to sell. Create them in RevenueCat dashboard.

**Packages** are individual subscription or purchase options (e.g., Monthly, Annual).

```typescript
import { useCurrentOffering } from '@ridwan-retainer/paywall';

function ProductList() {
  const { offering, isLoading } = useCurrentOffering();

  if (isLoading) return <Loading />;

  return (
    <View>
      {offering?.availablePackages.map((pkg) => (
        <Text key={pkg.identifier}>
          {pkg.product.title} - {pkg.product.priceString}
        </Text>
      ))}
    </View>
  );
}
```

### Entitlements

**Entitlements** represent access to features (configured in RevenueCat dashboard).

```typescript
import { useEntitlement } from '@ridwan-retainer/paywall';

function PremiumButton() {
  const { hasEntitlement, isLoading } = useEntitlement('premium');

  return (
    <Button disabled={!hasEntitlement}>
      {hasEntitlement ? 'Premium Feature' : 'Upgrade to Access'}
    </Button>
  );
}
```

### Customer Info

**Customer Info** contains all subscription and purchase data for a user.

```typescript
import { useCustomerInfo } from '@ridwan-retainer/paywall';

function SubscriptionStatus() {
  const { customerInfo, isLoading } = useCustomerInfo();

  const activeSub = customerInfo?.activeSubscriptions[0];

  return (
    <Text>
      Status: {activeSub ? 'Subscribed' : 'Free'}
    </Text>
  );
}
```

## 📚 API Reference

### Initialization

#### `useRevenueCatInitialization(config?: RevenueCatConfig)`

Initialize RevenueCat SDK. Call once at app startup.

```typescript
import { useRevenueCatInitialization } from '@ridwan-retainer/paywall';

function App() {
  const { isInitialized, error } = useRevenueCatInitialization({
    appUserID: 'user_123',           // Optional: Custom user ID
    observerMode: false,             // Optional: Observer mode (default: false)
    useAmazon: false,                // Optional: Use Amazon store (default: false)
  });

  return isInitialized ? <Main /> : <Splash />;
}
```

**Returns:**
```typescript
{
  isInitialized: boolean;
  error: Error | null;
}
```

#### `initializeRevenueCat(apiKey: string, config?: RevenueCatConfig): Promise<void>`

Programmatic initialization (alternative to hook).

```typescript
import { initializeRevenueCat } from '@ridwan-retainer/paywall';

await initializeRevenueCat('rcb_abc123', {
  appUserID: 'user_123',
});
```

### Hooks

#### `useOfferings()`

Get all available offerings.

```typescript
import { useOfferings } from '@ridwan-retainer/paywall';

function OfferingsList() {
  const { offerings, currentOffering, isLoading, error } = useOfferings();

  if (isLoading) return <Loading />;
  if (error) return <Error message={error.message} />;

  return (
    <View>
      {offerings?.all && Object.values(offerings.all).map((offering) => (
        <OfferingCard key={offering.identifier} offering={offering} />
      ))}
    </View>
  );
}
```

**Returns:**
```typescript
{
  offerings: PurchasesOfferings | null;
  currentOffering: PurchasesOffering | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}
```

#### `useCurrentOffering()`

Get the current (default) offering.

```typescript
import { useCurrentOffering } from '@ridwan-retainer/paywall';

function SubscriptionPlans() {
  const { offering, isLoading } = useCurrentOffering();

  return (
    <View>
      {offering?.availablePackages.map((pkg) => (
        <PlanCard key={pkg.identifier} package={pkg} />
      ))}
    </View>
  );
}
```

**Returns:**
```typescript
{
  offering: PurchasesOffering | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}
```

#### `useCustomerInfo()`

Get customer subscription information.

```typescript
import { useCustomerInfo } from '@ridwan-retainer/paywall';

function AccountScreen() {
  const { customerInfo, isLoading, refresh } = useCustomerInfo();

  const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
  const expirationDate = customerInfo?.entitlements.active['premium']?.expirationDate;

  return (
    <View>
      <Text>Status: {isPremium ? 'Premium' : 'Free'}</Text>
      {expirationDate && (
        <Text>Expires: {new Date(expirationDate).toLocaleDateString()}</Text>
      )}
      <Button onPress={refresh}>Refresh</Button>
    </View>
  );
}
```

**Returns:**
```typescript
{
  customerInfo: CustomerInfo | null;
  isLoading: boolean;
  error: Error | null;
  refresh: () => Promise<void>;
}
```

#### `useEntitlement(entitlementId: string)`

Check if user has a specific entitlement.

```typescript
import { useEntitlement } from '@ridwan-retainer/paywall';

function PremiumFeatureButton() {
  const { hasEntitlement, isLoading, entitlementInfo } = useEntitlement('premium');

  if (isLoading) return <Loading />;

  return (
    <Button disabled={!hasEntitlement}>
      {hasEntitlement ? 'Access Premium' : 'Upgrade Required'}
    </Button>
  );
}
```

**Returns:**
```typescript
{
  hasEntitlement: boolean;
  entitlementInfo: PurchasesEntitlementInfo | null;
  isLoading: boolean;
  error: Error | null;
}
```

#### `usePurchase()`

Handle purchase flow.

```typescript
import { usePurchase } from '@ridwan-retainer/paywall';

function BuyButton({ package: pkg }) {
  const { purchase, isPurchasing, error } = usePurchase();

  const handlePurchase = async () => {
    try {
      const result = await purchase(pkg);
      console.log('Purchase successful:', result);
      // Show success message
    } catch (err) {
      console.error('Purchase failed:', err);
      // Show error message
    }
  };

  return (
    <Button onPress={handlePurchase} disabled={isPurchasing}>
      {isPurchasing ? 'Processing...' : `Buy ${pkg.product.priceString}`}
    </Button>
  );
}
```

**Returns:**
```typescript
{
  purchase: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
  isPurchasing: boolean;
  error: PurchaseError | null;
}
```

#### `useRestorePurchases()`

Restore previous purchases.

```typescript
import { useRestorePurchases } from '@ridwan-retainer/paywall';

function RestoreButton() {
  const { restore, isRestoring, error } = useRestorePurchases();

  const handleRestore = async () => {
    try {
      const result = await restore();
      if (result.customerInfo.activeSubscriptions.length > 0) {
        Alert.alert('Success', 'Purchases restored!');
      } else {
        Alert.alert('No Purchases', 'No purchases found to restore.');
      }
    } catch (err) {
      Alert.alert('Error', 'Failed to restore purchases');
    }
  };

  return (
    <Button onPress={handleRestore} disabled={isRestoring}>
      {isRestoring ? 'Restoring...' : 'Restore Purchases'}
    </Button>
  );
}
```

**Returns:**
```typescript
{
  restore: () => Promise<RestoreResult>;
  isRestoring: boolean;
  error: RestoreError | null;
}
```

#### `usePaywall()`

Access paywall controller for programmatic control.

```typescript
import { usePaywall } from '@ridwan-retainer/paywall';

function CustomPaywall() {
  const { showPaywall, dismissPaywall } = usePaywall();

  const handleShowPaywall = () => {
    showPaywall({
      onPurchaseCompleted: (info) => {
        console.log('Purchase completed:', info);
        dismissPaywall();
      },
      onDismiss: () => {
        console.log('Paywall dismissed');
      },
    });
  };

  return <Button onPress={handleShowPaywall}>Upgrade</Button>;
}
```

### Components

#### `<Paywall />`

Full-screen RevenueCat paywall UI.

```typescript
import { Paywall } from '@ridwan-retainer/paywall';

<Paywall
  onPurchaseStarted={({ packageBeingPurchased }) => {
    console.log('Starting purchase:', packageBeingPurchased);
  }}
  onPurchaseCompleted={({ customerInfo, storeTransaction }) => {
    console.log('Purchase completed!');
    navigation.goBack();
  }}
  onPurchaseError={({ error }) => {
    Alert.alert('Purchase Failed', error.message);
  }}
  onPurchaseCancelled={() => {
    console.log('User cancelled purchase');
  }}
  onRestoreCompleted={({ customerInfo }) => {
    Alert.alert('Success', 'Purchases restored!');
  }}
  onRestoreError={({ error }) => {
    Alert.alert('Error', 'Restore failed');
  }}
  onDismiss={() => {
    navigation.goBack();
  }}
/>
```

**Props:**
```typescript
{
  onPurchaseStarted?: (data: { packageBeingPurchased: PurchasesPackage }) => void;
  onPurchaseCompleted?: (data: { customerInfo: CustomerInfo; storeTransaction: any }) => void;
  onPurchaseError?: (data: { error: PurchasesError }) => void;
  onPurchaseCancelled?: () => void;
  onRestoreCompleted?: (data: { customerInfo: CustomerInfo }) => void;
  onRestoreError?: (data: { error: PurchasesError }) => void;
  onDismiss?: () => void;
}
```

#### `<SubscriptionGate />`

Show content only to subscribers.

```typescript
import { SubscriptionGate } from '@ridwan-retainer/paywall';

<SubscriptionGate>
  <PremiumContent />
</SubscriptionGate>

// Automatically redirects non-subscribers to paywall
```

#### `<PaywallGate />`

Control feature access with modal paywall.

```typescript
import { PaywallGate } from '@ridwan-retainer/paywall';

<PaywallGate
  entitlementId="premium"
  onAccessGranted={() => {
    console.log('User now has access!');
  }}
>
  <View>
    <Text>Premium Feature</Text>
    <Button title="Click to Access" />
  </View>
</PaywallGate>
```

**Props:**
```typescript
{
  entitlementId: string;
  children: ReactNode;
  onAccessGranted?: () => void;
}
```

#### `<PurchaseButton />`

Pre-styled purchase button.

```typescript
import { PurchaseButton } from '@ridwan-retainer/paywall';

function SubscriptionPlan({ package: pkg }) {
  return (
    <PurchaseButton
      package={pkg}
      title={`Subscribe for ${pkg.product.priceString}`}
      onSuccess={() => {
        Alert.alert('Welcome!', 'You are now subscribed!');
      }}
      onError={(error) => {
        Alert.alert('Error', error.message);
      }}
      style={{ backgroundColor: '#007AFF', padding: 16 }}
    />
  );
}
```

**Props:**
```typescript
{
  package: PurchasesPackage;
  onSuccess?: () => void;
  onError?: (error: Error) => void;
  title?: string;
  style?: ViewStyle;
}
```

#### `<RestoreButton />`

Pre-styled restore purchases button.

```typescript
import { RestoreButton } from '@ridwan-retainer/paywall';

<RestoreButton
  onSuccess={() => {
    Alert.alert('Success', 'Purchases restored!');
  }}
  onError={(error) => {
    Alert.alert('Error', error.message);
  }}
  title="Restore Purchases"
  style={{ padding: 12 }}
/>
```

**Props:**
```typescript
{
  onSuccess?: () => void;
  onError?: (error: Error) => void;
  title?: string;
  style?: ViewStyle;
}
```

#### `<PaywallFooter />`

Displays legal text required for App Store compliance.

```typescript
import { PaywallFooter } from '@ridwan-retainer/paywall';

<PaywallFooter
  termsUrl="https://yourapp.com/terms"
  privacyUrl="https://yourapp.com/privacy"
/>
```

### Managers

For advanced use cases, you can use managers directly:

#### `offeringsManager`

```typescript
import { offeringsManager } from '@ridwan-retainer/paywall';

// Fetch offerings
const offerings = await offeringsManager.fetchOfferings();

// Get cached offerings
const cached = offeringsManager.getCachedOfferings();
```

#### `customerInfoManager`

```typescript
import { customerInfoManager } from '@ridwan-retainer/paywall';

// Get customer info
const info = await customerInfoManager.getCustomerInfo();

// Check entitlement
const hasAccess = await customerInfoManager.hasEntitlement('premium');
```

#### `purchaseManager`

```typescript
import { purchaseManager } from '@ridwan-retainer/paywall';

// Make purchase
const result = await purchaseManager.purchasePackage(package);
```

#### `restoreManager`

```typescript
import { restoreManager } from '@ridwan-retainer/paywall';

// Restore purchases
const result = await restoreManager.restorePurchases();
```

## 💡 Complete Examples

### Example 1: Subscription Paywall Screen

```typescript
import React from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import {
  useCurrentOffering,
  PurchaseButton,
  RestoreButton,
  PaywallFooter,
} from '@ridwan-retainer/paywall';

function SubscriptionScreen() {
  const { offering, isLoading } = useCurrentOffering();

  if (isLoading) {
    return <LoadingSpinner />;
  }

  if (!offering) {
    return <ErrorView message="No plans available" />;
  }

  return (
    <ScrollView style={styles.container}>
      <Text style={styles.title}>Upgrade to Premium</Text>
      <Text style={styles.subtitle}>
        Unlock all features and support development
      </Text>

      <View style={styles.features}>
        <FeatureRow icon="✓" text="Unlimited access" />
        <FeatureRow icon="✓" text="Priority support" />
        <FeatureRow icon="✓" text="Remove ads" />
        <FeatureRow icon="✓" text="Cloud sync" />
      </View>

      {offering.availablePackages.map((pkg) => (
        <View key={pkg.identifier} style={styles.plan}>
          <Text style={styles.planTitle}>{pkg.product.title}</Text>
          <Text style={styles.planPrice}>{pkg.product.priceString}</Text>
          <PurchaseButton
            package={pkg}
            title="Subscribe"
            style={styles.button}
            onSuccess={() => {
              navigation.goBack();
            }}
          />
        </View>
      ))}

      <RestoreButton
        title="Restore Purchases"
        style={styles.restoreButton}
      />

      <PaywallFooter
        termsUrl="https://yourapp.com/terms"
        privacyUrl="https://yourapp.com/privacy"
      />
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20 },
  title: { fontSize: 28, fontWeight: 'bold', textAlign: 'center' },
  subtitle: { fontSize: 16, color: '#666', textAlign: 'center', marginTop: 8 },
  features: { marginVertical: 24 },
  plan: { marginVertical: 12, padding: 16, borderWidth: 1, borderRadius: 8 },
  button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, marginTop: 12 },
  restoreButton: { marginTop: 16, padding: 12 },
});
```

### Example 2: Feature Gating

```typescript
import { useEntitlement } from '@ridwan-retainer/paywall';

function AdvancedSettings() {
  const { hasEntitlement, isLoading } = useEntitlement('premium');

  if (isLoading) return <Loading />;

  return (
    <View>
      <SettingItem title="Basic Setting" />
      <SettingItem title="Another Setting" />
      
      {hasEntitlement ? (
        <>
          <SettingItem title="Premium Setting 1" />
          <SettingItem title="Premium Setting 2" />
          <SettingItem title="Advanced Options" />
        </>
      ) : (
        <UpgradePrompt
          message="Unlock advanced settings with Premium"
          onPress={() => navigation.navigate('Subscription')}
        />
      )}
    </View>
  );
}
```

### Example 3: Usage-Based Limits

```typescript
import { useCustomerInfo } from '@ridwan-retainer/paywall';
import { useState, useEffect } from 'react';

function ExportFeature() {
  const { customerInfo } = useCustomerInfo();
  const [exportCount, setExportCount] = useState(0);
  
  const isPremium = customerInfo?.entitlements.active['premium']?.isActive;
  const maxExports = isPremium ? Infinity : 3;
  const canExport = exportCount < maxExports;

  const handleExport = async () => {
    if (!canExport && !isPremium) {
      navigation.navigate('Subscription');
      return;
    }

    await performExport();
    setExportCount(prev => prev + 1);
  };

  return (
    <View>
      {!isPremium && (
        <Text>{maxExports - exportCount} exports remaining</Text>
      )}
      <Button
        title="Export"
        onPress={handleExport}
        disabled={!canExport && !isPremium}
      />
    </View>
  );
}
```

## 🧪 Testing

### Test Purchases

#### iOS
1. Use Sandbox test users (App Store Connect → Users and Access → Sandbox Testers)
2. Sign in with test account on device (Settings → App Store → Sandbox Account)
3. Make purchases (won't charge real money)

#### Android
1. Add test users in Google Play Console
2. Use license testing or internal testing track
3. Make purchases with test account

### Test in Development

```typescript
import { initializeRevenueCat } from '@ridwan-retainer/paywall';

// Enable debug logging
await initializeRevenueCat(API_KEY, {
  appUserID: `test_user_${Date.now()}`, // Unique test user
});
```

### Mock for Unit Tests

```typescript
jest.mock('@ridwan-retainer/paywall', () => ({
  useCustomerInfo: () => ({
    customerInfo: {
      entitlements: {
        active: {
          premium: { isActive: true },
        },
      },
    },
    isLoading: false,
  }),
}));
```

## ✅ Best Practices

### 1. Check Subscription Status Regularly

```typescript
// On app open or resume
useEffect(() => {
  customerInfoManager.getCustomerInfo({ forceRefresh: true });
}, []);
```

### 2. Handle Errors Gracefully

```typescript
const { purchase, error } = usePurchase();

useEffect(() => {
  if (error) {
    if (error.userCancelled) {
      // User cancelled, no need to show error
      return;
    }
    Alert.alert('Purchase Error', getUserFriendlyErrorMessage(error));
  }
}, [error]);
```

### 3. Provide Restore Option

Always show a "Restore Purchases" button for users who reinstalled the app.

### 4. Cache Appropriately

The library caches offerings and customer info automatically, but you can force refresh:

```typescript
const { refresh } = useCustomerInfo();

// Force refresh on pull-to-refresh
<ScrollView
  refreshControl={
    <RefreshControl refreshing={isLoading} onRefresh={refresh} />
  }
>
```

### 5. Test Both Platforms

Subscription behavior differs between iOS and Android. Test thoroughly on both.

### 6. Follow App Store Guidelines

- Show clear pricing
- Include terms and privacy links
- Provide restore functionality
- Handle subscription management

## 🐛 Troubleshooting

### Issue: "Unable to find offerings"

**Solution**: Check RevenueCat dashboard configuration:
1. Verify offerings are created
2. Check products are added to offerings
3. Ensure API keys are correct

### Issue: "Invalid product identifiers"

**Solution**: Product IDs must match exactly between:
- App Store Connect / Google Play Console
- RevenueCat dashboard
- Your code

### Issue: Purchases not working in production

**Solution**:
1. Use production API keys (not sandbox)
2. Verify app is live in stores
3. Check RevenueCat integration status
4. Test with real Apple/Google account

### Issue: "Could not find customer"

**Solution**: Initialize RevenueCat before making any calls:

```typescript
const { isInitialized } = useRevenueCatInitialization();

if (!isInitialized) {
  return <Loading />;
}
```

### Issue: Subscription status not updating

**Solution**: Force refresh customer info:

```typescript
const { refresh } = useCustomerInfo();
await refresh();
```

## 🤝 Contributing

Contributions welcome! Please submit Pull Requests.

## 📄 License

MIT © [Ridwan Hamid](https://github.com/RidwanHamid501)

## 🔗 Links

- [npm Package](https://www.npmjs.com/package/@ridwan-retainer/paywall)
- [GitHub Repository](https://github.com/RidwanHamid501/app-factory/tree/main/packages/paywall)
- [RevenueCat Documentation](https://docs.revenuecat.com)
- [RevenueCat React Native SDK](https://github.com/RevenueCat/react-native-purchases)

## 📞 Support

- [GitHub Issues](https://github.com/RidwanHamid501/app-factory/issues)
- [RevenueCat Support](https://community.revenuecat.com)