unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
1,063 lines (903 loc) • 40.3 kB
text/typescript
import { PaywallConfig } from '@unified-video/core';
import { EmailAuthController, EmailAuthControllerOptions } from './EmailAuthController';
export type PaywallGateway = {
id: string;
name: string;
description?: string;
icon?: string;
color?: string;
};
export type PaymentLinkConfig = {
endpoint: string;
method?: 'POST' | 'GET';
headers?: Record<string, string>;
mapRequest?: (paymentData: any) => any;
mapResponse?: (response: any) => { url: string; orderId?: string; };
popup?: {
width?: number;
height?: number;
features?: string;
};
};
export type PaywallControllerOptions = {
getOverlayContainer: () => HTMLElement | null;
onResume: (accessInfo?: { accessGranted?: boolean; paymentSuccessful?: boolean }) => void;
onShow?: () => void;
onClose?: () => void;
// Custom payment handlers
onPaymentRequested?: (gateway: PaywallGateway, paymentData: any) => Promise<void> | void;
onPaymentSuccess?: (gateway: PaywallGateway, result: any) => void;
onPaymentError?: (gateway: PaywallGateway, error: any) => void;
onPaymentCancel?: (gateway: PaywallGateway) => void;
};
export class PaywallController {
private config: PaywallConfig | null = null;
private opts: PaywallControllerOptions;
private overlayEl: HTMLElement | null = null;
private gatewayStepEl: HTMLElement | null = null;
private popup: Window | null = null;
private emailAuth: EmailAuthController | null = null;
private authenticatedUserId: string | null = null;
private sessionToken: string | null = null;
private currentGateway: PaywallGateway | null = null; // Track current payment gateway
constructor(config: PaywallConfig | null, opts: PaywallControllerOptions) {
this.config = config;
this.opts = opts;
// Initialize EmailAuthController if email auth is enabled
this.initializeEmailAuth();
// Don't check authentication immediately - allow free preview first
// Authentication will be triggered when free preview ends
try {
window.addEventListener('message', this.onMessage, false);
} catch (_) {}
}
updateConfig(config: PaywallConfig | null) {
// Defensive logic: if new config is null/undefined but we have a working email auth,
// preserve the email auth instance to prevent destruction during re-initialization
const hadWorkingEmailAuth = this.config?.emailAuth?.enabled && !!this.emailAuth;
const newConfigLacksEmailAuth = !config?.emailAuth?.enabled;
if (hadWorkingEmailAuth && newConfigLacksEmailAuth) {
console.log('[PaywallController] Preserving email auth instance during config update');
// Only update non-email auth related config, keep the email auth part
this.config = {
...config,
emailAuth: this.config?.emailAuth // Preserve existing email auth config
};
if (this.emailAuth) {
this.emailAuth.updateConfig(this.config);
}
} else {
// Normal config update
this.config = config;
this.initializeEmailAuth();
if (this.emailAuth) {
this.emailAuth.updateConfig(config);
}
}
}
openOverlay() {
console.log('[PaywallController] openOverlay called');
console.log('[PaywallController] config enabled:', this.config?.enabled);
console.log('[PaywallController] email auth enabled:', this.config?.emailAuth?.enabled);
console.log('[PaywallController] emailAuth instance:', !!this.emailAuth);
if (!this.config?.enabled) {
console.log('[PaywallController] Paywall disabled, exiting');
return;
}
// Check authentication first if email auth is enabled
if (this.config.emailAuth?.enabled) {
console.log('[PaywallController] Email auth is enabled, checking authentication');
// If email auth is enabled but instance doesn't exist, try to initialize it
if (!this.emailAuth) {
console.log('[PaywallController] Email auth enabled but no instance found, initializing now');
this.initializeEmailAuth();
}
// If still no instance after initialization, show error
if (!this.emailAuth) {
console.error('[PaywallController] Failed to initialize email auth, proceeding to payment overlay');
// Continue to payment overlay as fallback
} else {
const isAuthenticated = this.emailAuth.isAuthenticated();
console.log('[PaywallController] User authenticated:', isAuthenticated);
if (!isAuthenticated) {
console.log('[PaywallController] User not authenticated, opening email auth modal');
// Show email authentication modal first
this.emailAuth.openAuthModal();
return;
} else {
console.log('[PaywallController] User already authenticated, proceeding to payment overlay');
// Update userId for authenticated user
this.authenticatedUserId = this.emailAuth.getAuthenticatedUserId() || this.config.userId || null;
// Update config with authenticated userId for API calls
if (this.authenticatedUserId && this.config) {
this.config.userId = this.authenticatedUserId;
}
}
}
}
// Show payment overlay
console.log('[PaywallController] Showing payment overlay');
const root = this.ensureOverlay();
if (!root) {
console.log('[PaywallController] Failed to create overlay');
return;
}
// Show overlay with proper animation
root.style.display = 'flex';
root.classList.add('active');
// Force reflow then fade in with animation
void root.offsetWidth;
root.style.opacity = '1';
// Also animate the modal inside
const modal = root.querySelector('.uvf-paywall-modal') as HTMLElement;
if (modal) {
modal.style.transform = 'translateY(0)';
modal.style.opacity = '1';
}
console.log('[PaywallController] Payment overlay displayed successfully');
this.opts.onShow?.();
}
closeOverlay() {
console.log('[PaywallController] closeOverlay called');
// First, close any auth modals that might be open
if (this.emailAuth) {
console.log('[PaywallController] Closing auth modal if open');
this.emailAuth.closeAuthModal();
}
// Call onClose immediately before animation to ensure security state is reset
this.opts.onClose?.();
if (this.overlayEl) {
console.log('[PaywallController] Animating paywall overlay out');
// Immediately hide to prevent user interaction during animation
this.overlayEl.style.pointerEvents = 'none';
// Animate out
this.overlayEl.style.opacity = '0';
const modal = this.overlayEl.querySelector('.uvf-paywall-modal') as HTMLElement;
if (modal) {
modal.style.transform = 'translateY(20px)';
modal.style.opacity = '0';
}
// Hide after animation
setTimeout(() => {
if (this.overlayEl) {
this.overlayEl.classList.remove('active');
this.overlayEl.style.display = 'none';
this.overlayEl.style.pointerEvents = '';
console.log('[PaywallController] Paywall overlay hidden after animation');
}
}, 300); // Match the CSS transition duration
}
// Also ensure any leftover overlays are cleaned up
const container = this.opts.getOverlayContainer() || document.body;
const allOverlays = container.querySelectorAll('.uvf-paywall-overlay, .uvf-auth-overlay');
allOverlays.forEach((overlay: Element) => {
const htmlOverlay = overlay as HTMLElement;
if (htmlOverlay && htmlOverlay.style.display !== 'none') {
console.log('[PaywallController] Force hiding leftover overlay:', htmlOverlay.className);
htmlOverlay.style.display = 'none';
htmlOverlay.classList.remove('active');
}
});
}
private ensureOverlay(): HTMLElement | null {
if (this.overlayEl && document.body.contains(this.overlayEl)) return this.overlayEl;
const container = this.opts.getOverlayContainer() || document.body;
const ov = document.createElement('div');
ov.className = 'uvf-paywall-overlay';
ov.setAttribute('role', 'dialog');
ov.setAttribute('aria-modal', 'true');
ov.style.cssText = `
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 2147483647;
display: none;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
`;
const modal = document.createElement('div');
modal.className = 'uvf-paywall-modal';
modal.style.cssText = `
width: 90vw;
height: 85vh;
max-width: 1000px;
max-height: 700px;
background: #0f0f10;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.7),
0 0 0 1px rgba(255, 255, 255, 0.1);
transform: translateY(20px);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
`;
const header = document.createElement('div');
header.style.cssText = 'display:flex;gap:16px;align-items:center;padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.1)';
const hTitle = document.createElement('div');
hTitle.textContent = (this.config?.branding?.title || 'Continue watching');
hTitle.style.cssText = 'color:#fff;font-size:18px;font-weight:700';
const hDesc = document.createElement('div');
hDesc.textContent = (this.config?.branding?.description || 'Rent to continue watching this video.');
hDesc.style.cssText = 'color:rgba(255,255,255,0.75);font-size:14px;margin-top:4px';
const headerTextWrap = document.createElement('div');
headerTextWrap.appendChild(hTitle); headerTextWrap.appendChild(hDesc);
header.appendChild(headerTextWrap);
const content = document.createElement('div');
content.style.cssText = 'flex:1;display:flex;align-items:center;justify-content:center;padding:20px;';
const intro = document.createElement('div');
intro.style.cssText = 'display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;';
const msg = document.createElement('div');
msg.textContent = 'Free preview ended. Rent to continue watching.';
msg.style.cssText = 'color:#fff;font-size:16px;';
const rentBtn = document.createElement('button');
rentBtn.textContent = 'Rent Now';
rentBtn.className = 'uvf-btn-primary';
rentBtn.style.cssText = 'background:linear-gradient(135deg,#ff4d4f,#d9363e);color:#fff;border:1px solid rgba(255,77,79,0.6);border-radius:999px;padding:10px 18px;cursor:pointer;';
rentBtn.addEventListener('click', () => this.showGateways());
intro.appendChild(msg); intro.appendChild(rentBtn);
const step = document.createElement('div');
step.style.cssText = 'display:none;flex-direction:column;gap:16px;align-items:center;justify-content:center;';
this.gatewayStepEl = step;
content.appendChild(intro);
content.appendChild(step);
modal.appendChild(header); modal.appendChild(content);
ov.appendChild(modal);
container.appendChild(ov);
this.overlayEl = ov;
return ov;
}
/**
* Completely destroy and remove all overlays
*/
destroyOverlays() {
console.log('[PaywallController] destroyOverlays called');
// Close and destroy auth modal
if (this.emailAuth) {
this.emailAuth.closeAuthModal();
}
// Remove paywall overlay
if (this.overlayEl && this.overlayEl.parentNode) {
this.overlayEl.parentNode.removeChild(this.overlayEl);
this.overlayEl = null;
}
// Find and remove any leftover overlays
const container = this.opts.getOverlayContainer() || document.body;
const allOverlays = container.querySelectorAll('.uvf-paywall-overlay, .uvf-auth-overlay');
allOverlays.forEach((overlay: Element) => {
if (overlay.parentNode) {
console.log('[PaywallController] Destroying leftover overlay:', overlay.className);
overlay.parentNode.removeChild(overlay);
}
});
}
private showGateways() {
if (!this.config) {
console.error('[PaywallController] No config found in showGateways');
return;
}
console.log('[PaywallController] showGateways called');
console.log('[PaywallController] Config gateways:', this.config.gateways);
this.gatewayStepEl!.innerHTML = '';
this.gatewayStepEl!.style.display = 'flex';
const title = document.createElement('div');
title.textContent = this.config.branding?.paymentTitle || 'Choose a payment method';
title.style.cssText = 'color:#fff;font-size:16px;margin-bottom:20px;';
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:12px;flex-wrap:wrap;justify-content:center;';
// Support both legacy string array and new gateway objects
const gateways = this.getGateways();
console.log('[PaywallController] Processed gateways:', gateways);
if (gateways.length === 0) {
console.warn('[PaywallController] No gateways available');
const errorMsg = document.createElement('div');
errorMsg.textContent = 'No payment methods available. Please contact support.';
errorMsg.style.cssText = 'color:#ff6b6b;font-size:14px;text-align:center;padding:20px;';
wrap.appendChild(errorMsg);
} else {
let buttonsAdded = 0;
for (const gateway of gateways) {
console.log(`[PaywallController] Creating button for gateway:`, gateway);
const btn = this.createGatewayButton(gateway);
btn.addEventListener('click', () => this.handleGatewayClick(gateway));
wrap.appendChild(btn);
buttonsAdded++;
}
console.log(`[PaywallController] Added ${buttonsAdded} gateway buttons`);
}
// Hide intro step and show gateway step
const intro = this.overlayEl?.querySelector('div[style*="display:flex;flex-direction:column;gap:16px;align-items:center;justify-content:center;"]') as HTMLElement;
if (intro) {
intro.style.display = 'none';
}
this.gatewayStepEl!.appendChild(title);
this.gatewayStepEl!.appendChild(wrap);
console.log('[PaywallController] Gateway step UI updated');
}
private getGateways(): PaywallGateway[] {
if (!this.config?.gateways) return [];
return this.config.gateways.map((g: any) => {
if (typeof g === 'string') {
// Legacy support for string arrays
return this.getLegacyGateway(g);
}
// New gateway object format
return g as PaywallGateway;
});
}
private getLegacyGateway(id: string): PaywallGateway {
const legacyGateways: Record<string, PaywallGateway> = {
stripe: {
id: 'stripe',
name: 'Credit/Debit Card',
description: 'Pay with Stripe',
color: '#6772e5'
},
cashfree: {
id: 'cashfree',
name: 'UPI/Netbanking',
description: 'Pay with Cashfree',
color: '#00d4aa'
},
payu: {
id: 'payu',
name: 'PayU',
description: 'Pay with PayU',
color: '#17bf43'
},
custom: {
id: 'custom',
name: 'Pay Now',
description: 'Secure Payment',
color: '#4f9eff'
}
};
console.log(`[PaywallController] Converting legacy gateway: ${id}`);
const gateway = legacyGateways[id] || {
id,
name: id.charAt(0).toUpperCase() + id.slice(1),
description: `Pay with ${id}`,
color: '#666666'
};
console.log(`[PaywallController] Converted to:`, gateway);
return gateway;
}
private createGatewayButton(gateway: PaywallGateway): HTMLButtonElement {
const btn = document.createElement('button');
btn.className = 'uvf-gateway-btn';
// Create button content
const content = document.createElement('div');
content.style.cssText = 'display:flex;flex-direction:column;align-items:center;gap:8px;';
// Icon or emoji
if (gateway.icon) {
const icon = document.createElement('div');
icon.innerHTML = gateway.icon;
icon.style.cssText = 'font-size:24px;';
content.appendChild(icon);
}
// Gateway name
const name = document.createElement('div');
name.textContent = gateway.name;
name.style.cssText = 'font-weight:600;font-size:14px;';
content.appendChild(name);
// Description (optional)
if (gateway.description) {
const desc = document.createElement('div');
desc.textContent = gateway.description;
desc.style.cssText = 'font-size:12px;opacity:0.8;';
content.appendChild(desc);
}
btn.appendChild(content);
// Styling
const bgColor = gateway.color || '#4f9eff';
btn.style.cssText = `
background: linear-gradient(135deg, ${bgColor}, ${this.adjustBrightness(bgColor, -20)});
color: #fff;
border: none;
border-radius: 12px;
padding: 16px 20px;
cursor: pointer;
min-width: 140px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
font-family: inherit;
`;
// Hover effects
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'translateY(-2px)';
btn.style.boxShadow = `0 8px 20px rgba(0,0,0,0.3), 0 4px 8px ${bgColor}40`;
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = 'translateY(0)';
btn.style.boxShadow = 'none';
});
return btn;
}
private adjustBrightness(color: string, amount: number): string {
// Simple color brightness adjustment
if (!color.startsWith('#')) return color;
const num = parseInt(color.slice(1), 16);
const r = Math.max(0, Math.min(255, (num >> 16) + amount));
const g = Math.max(0, Math.min(255, ((num >> 8) & 0x00FF) + amount));
const b = Math.max(0, Math.min(255, (num & 0x0000FF) + amount));
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}
private async openGateway(gateway: 'stripe' | 'cashfree') {
try {
if (!this.config) return;
const { apiBase, userId, videoId } = this.config;
const w = Math.min(window.screen.width - 100, this.config.popup?.width || 1000);
const h = Math.min(window.screen.height - 100, this.config.popup?.height || 800);
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
if (gateway === 'stripe') {
const res = await fetch(`${apiBase}/api/rentals/stripe/checkout-session`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId, videoId,
successUrl: window.location.origin + window.location.pathname + '?rental=success&popup=1',
cancelUrl: window.location.origin + window.location.pathname + '?rental=cancel&popup=1'
})
});
const data = await res.json();
if (data?.url) {
try { this.popup && !this.popup.closed && this.popup.close(); } catch (_) {}
this.popup = window.open(data.url, 'uvfCheckout', `popup=1,width=${w},height=${h},left=${left},top=${top}`);
this.startPolling();
}
return;
}
if (gateway === 'cashfree') {
const features = `popup=1,width=${w},height=${h},left=${left},top=${top}`;
// Pre-open a blank popup in direct response to the click to avoid popup blockers
let pre: Window | null = null;
try { pre = window.open('', 'uvfCheckout', features); } catch(_) { pre = null; }
const res = await fetch(`${apiBase}/api/rentals/cashfree/order`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId, videoId, returnUrl: window.location.origin + window.location.pathname })
});
const data = await res.json();
if (data?.paymentLink && data?.orderId) {
try { this.popup && !this.popup.closed && this.popup.close(); } catch (_) {}
this.popup = pre && !pre.closed ? pre : window.open('', 'uvfCheckout', features);
try { if (this.popup) this.popup.location.href = data.paymentLink; } catch(_) {}
(window as any)._uvf_cfOrderId = data.orderId;
this.startPolling();
} else {
// Close the pre-opened popup if we didn't get a link
try { pre && !pre.closed && pre.close(); } catch(_) {}
}
return;
}
} catch (_) {
// noop
}
}
private startPolling() {
// basic polling to detect entitlement or popup closed; the host page should also listen to postMessage
const timer = setInterval(async () => {
if (!this.config) { clearInterval(timer); return; }
if (this.popup && this.popup.closed) {
clearInterval(timer);
// user cancelled; leave overlay open at gateway selection
this.showGateways();
return;
}
}, 3000);
}
// Handle gateway button clicks with flexible routing
private async handleGatewayClick(gateway: PaywallGateway) {
try {
// Track current gateway for message handling
this.currentGateway = gateway;
console.log(`[PaywallController] Processing payment for gateway: ${gateway.id}`);
// Check if user provided a custom payment handler
if (this.opts.onPaymentRequested) {
console.log(`[PaywallController] Using custom handler for gateway: ${gateway.id}`);
const paymentData = {
userId: this.authenticatedUserId || this.config?.userId,
videoId: this.config?.videoId,
amount: this.config?.pricing?.amount,
currency: this.config?.pricing?.currency || 'INR',
gateway: gateway.id,
sessionToken: this.sessionToken
};
await this.opts.onPaymentRequested(gateway, paymentData);
return;
}
// PRIORITY: Check for payment link configuration first
// This allows users to override built-in gateways with their own APIs
const paymentLinkConfig = (this.config as any)?.paymentLink;
if (paymentLinkConfig?.endpoint) {
console.log(`[PaywallController] Using payment link configuration for: ${gateway.id}`);
await this.handlePaymentLink(gateway);
return;
}
// Fallback: Handle built-in gateways (Stripe, Cashfree) only if no payment link config
if (gateway.id === 'stripe' || gateway.id === 'cashfree') {
console.log(`[PaywallController] Using built-in handler for: ${gateway.id}`);
await this.openGateway(gateway.id);
return;
}
// No handler available
console.error(`[PaywallController] No payment handler configured for gateway: ${gateway.id}`);
alert('Payment method not configured. Please contact support.');
} catch (error) {
console.error(`[PaywallController] Payment error for ${gateway.id}:`, error);
// Notify user of payment error via callback
if (this.opts.onPaymentError) {
this.opts.onPaymentError(gateway, error);
} else {
alert('Payment failed. Please try again or contact support.');
}
// Return to gateway selection
this.showGateways();
}
}
// Option B: config-only payment link handler
private async handlePaymentLink(gateway: PaywallGateway) {
const cfg = (this.config as any).paymentLink as PaymentLinkConfig | undefined;
if (!cfg?.endpoint) throw new Error('paymentLink.endpoint is required');
const w = Math.min(window.screen.width - 100, cfg.popup?.width || this.config?.popup?.width || 1000);
const h = Math.min(window.screen.height - 100, cfg.popup?.height || this.config?.popup?.height || 800);
const left = Math.max(0, Math.round((window.screen.width - w) / 2));
const top = Math.max(0, Math.round((window.screen.height - h) / 2));
const features = cfg.popup?.features || `popup=1,width=${w},height=${h},left=${left},top=${top}`;
// Pre-open popup to avoid blockers
let pre: Window | null = null;
try { pre = window.open('', 'uvfCheckout', features); } catch (_) { pre = null; }
const paymentData = {
userId: this.authenticatedUserId || this.config?.userId,
videoId: this.config?.videoId,
amount: this.config?.pricing?.amount,
currency: this.config?.pricing?.currency || 'INR',
metadata: { gateway: gateway.id, sessionToken: this.sessionToken, authenticatedUserId: this.authenticatedUserId }
};
// Use user's mapRequest function, or error if none provided
if (!cfg.mapRequest) {
throw new Error('paymentLink.mapRequest is required - please provide a function to map payment data to your API format');
}
console.log('[PaywallController] PaymentData passed to mapRequest:', paymentData);
const body = cfg.mapRequest(paymentData);
console.log('[PaywallController] Mapped request body:', body);
const res = await fetch(cfg.endpoint, {
method: cfg.method || 'POST',
headers: { 'Content-Type': 'application/json', ...(cfg.headers || {}) },
body: (cfg.method || 'POST') === 'POST' ? JSON.stringify(body) : undefined
});
console.log('[PaywallController] API response status:', res.status, res.statusText);
const raw = await res.json();
const mapped = cfg.mapResponse ? cfg.mapResponse(raw) : {
url: raw?.Payment_Link_URL || raw?.paymentLink || raw?.link_url,
orderId: raw?.order_id || raw?.orderId
};
if (!mapped?.url) {
// Close pre-opened popup if failed
try { pre && !pre.closed && pre.close(); } catch (_) {}
throw new Error(raw?.message || 'Failed to create payment link');
}
try { this.popup && !this.popup.closed && this.popup.close(); } catch (_) {}
this.popup = pre && !pre.closed ? pre : window.open('', 'uvfCheckout', features);
try { if (this.popup) this.popup.location.href = mapped.url; } catch(_) {}
// Store orderId and gateway context for later confirmation if needed
(window as any)._uvf_orderId = mapped.orderId || null;
(window as any)._uvf_gatewayId = gateway.id;
// Rely on success_url page to postMessage back with { type:'uvfCheckout', status:'success', orderId, gatewayId }
this.startPolling();
}
private onMessage = async (ev: MessageEvent) => {
const d: any = ev?.data || {};
if (!d || d.type !== 'uvfCheckout') return;
try { if (this.popup && !this.popup.closed) this.popup.close(); } catch (_) {}
this.popup = null;
// Determine which gateway was used based on the message data
const gateway = this.findGatewayById(d.gatewayId) || this.currentGateway || {
id: 'unknown',
name: 'Payment Gateway'
};
// Clear current gateway after processing
if (d.status === 'success' || d.status === 'cancel' || d.status === 'error') {
this.currentGateway = null;
}
if (d.status === 'cancel') {
console.log(`[PaywallController] Payment cancelled for gateway: ${gateway.id}`);
// Notify user callback of cancellation
if (this.opts.onPaymentCancel) {
this.opts.onPaymentCancel(gateway);
}
// Return to gateway selection
this.showGateways();
return;
}
if (d.status === 'success') {
console.log(`[PaywallController] Payment successful for gateway: ${gateway.id}`);
try {
// Handle built-in gateway verification
if (d.sessionId && this.config) {
console.log('[PaywallController] Verifying Stripe session');
await fetch(`${this.config.apiBase}/api/rentals/stripe/confirm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: d.sessionId,
userId: this.authenticatedUserId || this.config.userId,
videoId: this.config.videoId
})
});
}
if (d.orderId && this.config) {
console.log('[PaywallController] Verifying Cashfree order');
await fetch(`${this.config.apiBase}/api/rentals/cashfree/verify?orderId=${encodeURIComponent(d.orderId)}&userId=${encodeURIComponent(this.authenticatedUserId || this.config.userId || '')}&videoId=${encodeURIComponent(this.config.videoId || '')}`);
}
// For custom payment links or other gateways, verification might be handled differently
// The success callback will be triggered regardless
} catch (error) {
console.error('[PaywallController] Payment verification failed:', error);
// Notify error callback
if (this.opts.onPaymentError) {
this.opts.onPaymentError(gateway, error);
return;
}
}
console.log('[PaywallController] Payment verification completed, proceeding with success flow');
// Notify success callback
if (this.opts.onPaymentSuccess) {
console.log('[PaywallController] Calling onPaymentSuccess callback');
this.opts.onPaymentSuccess(gateway, {
sessionId: d.sessionId,
orderId: d.orderId,
transactionId: d.transactionId,
...d // Pass all data from the message
});
}
// Close overlay and resume playbook
console.log('[PaywallController] Closing overlay and resuming playbook');
// Use more aggressive cleanup to ensure all overlays are removed
this.destroyOverlays();
// Small delay to ensure DOM cleanup is complete before resuming
setTimeout(() => {
this.opts.onResume();
}, 50);
return;
}
// Handle error status
if (d.status === 'error') {
console.error(`[PaywallController] Payment error for gateway: ${gateway.id}`, d.error);
if (this.opts.onPaymentError) {
this.opts.onPaymentError(gateway, d.error || 'Payment failed');
}
// Return to gateway selection
this.showGateways();
}
};
// Helper method to find gateway by ID
private findGatewayById(gatewayId?: string): PaywallGateway | null {
if (!gatewayId) return null;
const gateways = this.getGateways();
return gateways.find(g => g.id === gatewayId) || null;
}
/**
* Initialize EmailAuthController if email authentication is enabled
*/
private initializeEmailAuth() {
console.log('[PaywallController] initializeEmailAuth called');
console.log('[PaywallController] email auth config:', this.config?.emailAuth);
console.log('[PaywallController] config enabled:', this.config?.enabled);
// If paywall is disabled entirely, clean up everything
if (!this.config?.enabled) {
console.log('[PaywallController] Paywall completely disabled, cleaning up email auth');
if (this.emailAuth) {
this.emailAuth.destroy();
this.emailAuth = null;
}
return;
}
// If email auth specifically is disabled, clean up only email auth
if (!this.config?.emailAuth?.enabled) {
console.log('[PaywallController] Email auth disabled, cleaning up existing instance');
if (this.emailAuth) {
this.emailAuth.destroy();
this.emailAuth = null;
}
return;
}
console.log('[PaywallController] Email auth enabled, checking for existing instance:', !!this.emailAuth);
if (!this.emailAuth) {
console.log('[PaywallController] Creating new EmailAuthController');
const emailAuthOptions: EmailAuthControllerOptions = {
getOverlayContainer: this.opts.getOverlayContainer,
onAuthSuccess: (userId: string, sessionToken: string, accessData?: any) => {
this.authenticatedUserId = userId;
this.sessionToken = sessionToken;
// Update config with authenticated userId
if (this.config) {
this.config.userId = userId;
}
// Close auth modal
this.emailAuth?.closeAuthModal();
// Handle access logic based on server response
// Handle access logic based on server response
if (accessData) {
// Support both camelCase (from EmailAuthController) and snake_case (legacy)
const access_granted = accessData.accessGranted ?? accessData.access_granted ?? false;
const requires_payment = accessData.requiresPayment ?? accessData.requires_payment ?? false;
const free_duration = accessData.freeDuration ?? accessData.free_duration ?? 0;
const price = accessData.price ?? null;
// Update price from server response if provided
if (price && this.config) {
this.config.pricing = {
...this.config.pricing,
amount: parseFloat(price.toString().replace(/[^\d.]/g, ''))
};
}
console.log('[PaywallController] Auth response:', { access_granted, requires_payment, free_duration });
if (access_granted) {
// Full access - play immediately
console.log('[PaywallController] Access granted, cleaning up overlays and playing video');
// Use more aggressive cleanup to ensure all overlays are removed
this.destroyOverlays();
// Small delay to ensure DOM cleanup is complete before resuming
setTimeout(() => {
// Pass access granted status to WebPlayer via onResume callback
if (this.opts.onResume) {
this.opts.onResume({ accessGranted: true, paymentSuccessful: true });
}
}, 50);
}
else if (!access_granted && requires_payment) {
if (free_duration > 0) {
// Start free preview, show paywall after duration
console.log(`[PaywallController] Starting ${free_duration}s preview`);
this.opts.onResume();
// Let preview play, WebPlayer will handle showing paywall
// after free_duration via onFreePreviewEnded event
} else {
// No preview available - show paywall immediately
console.log('[PaywallController] No preview available, showing paywall');
setTimeout(() => {
this.openPaymentOverlay();
}, 100);
}
}
else {
// Default behavior - resume playback
console.log('[PaywallController] Default behavior, resuming playback');
this.opts.onResume();
}
} else {
// Backward compatibility - use configured free duration
console.log('[PaywallController] No access data, resuming with default preview');
this.opts.onResume();
}
},
onAuthCancel: () => {
// User cancelled authentication, close everything
this.emailAuth?.closeAuthModal();
this.opts.onShow?.(); // Let parent know modal was shown (for cleanup)
},
onShow: this.opts.onShow,
onClose: this.opts.onClose,
};
this.emailAuth = new EmailAuthController(this.config, emailAuthOptions);
console.log('[PaywallController] EmailAuthController created successfully');
}
}
/**
* Open payment overlay directly (bypassing auth check)
*/
private openPaymentOverlay() {
console.log('[PaywallController] Opening payment overlay');
const root = this.ensureOverlay();
if (!root) {
console.error('[PaywallController] Failed to create overlay');
return;
}
try {
root.style.display = 'flex';
root.classList.add('active');
// Force reflow then fade in with animation
void root.offsetWidth;
root.style.opacity = '1';
// Also animate the modal inside
const modal = root.querySelector('.uvf-paywall-modal') as HTMLElement;
if (modal) {
modal.style.transform = 'translateY(0)';
modal.style.opacity = '1';
}
this.opts.onShow?.();
console.log('[PaywallController] Payment overlay shown');
} catch (err) {
console.error('[PaywallController] Error showing overlay:', err);
}
}
/**
* Check if user is authenticated (for external use)
*/
isAuthenticated(): boolean {
if (!this.config?.emailAuth?.enabled) return true;
return this.emailAuth?.isAuthenticated() || false;
}
/**
* Get authenticated user ID (for external use)
*/
getAuthenticatedUserId(): string | null {
if (!this.config?.emailAuth?.enabled) return this.config?.userId || null;
return this.emailAuth?.getAuthenticatedUserId() || this.config?.userId || null;
}
/**
* Logout user (for external use)
*/
async logout(): Promise<void> {
if (this.emailAuth) {
await this.emailAuth.logout();
}
this.authenticatedUserId = null;
this.sessionToken = null;
}
/**
* Add a custom payment gateway dynamically
*/
addGateway(gateway: PaywallGateway) {
if (!this.config) {
console.warn('[PaywallController] Cannot add gateway: config is null');
return;
}
if (!this.config.gateways) {
this.config.gateways = [];
}
// Remove existing gateway with same ID
this.config.gateways = this.config.gateways.filter((g: any) => {
const id = typeof g === 'string' ? g : g.id;
return id !== gateway.id;
});
// Add new gateway
this.config.gateways.push(gateway);
console.log(`[PaywallController] Added gateway: ${gateway.id}`);
}
/**
* Remove a payment gateway by ID
*/
removeGateway(gatewayId: string) {
if (!this.config?.gateways) return;
this.config.gateways = this.config.gateways.filter((g: any) => {
const id = typeof g === 'string' ? g : g.id;
return id !== gatewayId;
});
console.log(`[PaywallController] Removed gateway: ${gatewayId}`);
}
/**
* Get all configured gateways (for external use)
*/
getConfiguredGateways(): PaywallGateway[] {
return this.getGateways();
}
/**
* Cleanup on destroy
*/
destroy() {
if (this.emailAuth) {
this.emailAuth.destroy();
this.emailAuth = null;
}
if (this.overlayEl && this.overlayEl.parentElement) {
this.overlayEl.parentElement.removeChild(this.overlayEl);
}
this.overlayEl = null;
// Close any open popup
try {
if (this.popup && !this.popup.closed) {
this.popup.close();
}
} catch (_) {}
this.popup = null;
// Clean up gateway tracking
this.currentGateway = null;
try {
window.removeEventListener('message', this.onMessage, false);
} catch (_) {}
}
}