import React, { useEffect, useState } from "react"; import { View, Text, StyleSheet, Modal, TouchableOpacity, ActivityIndicator, ScrollView, Platform, Pressable, Linking, Image, } from "react-native"; import { Ionicons } from "@expo/vector-icons"; import { LinearGradient } from "expo-linear-gradient"; import { router } from "expo-router"; import { useAuthStore, useSubscriptionStore } from "../../store"; import { colors, gradients, spacing, radius, typography, shadows } from "../../theme"; import type { PurchasesPackage } from "../../lib/revenuecat"; import { Events } from "../../lib/analytics"; const logoImage = require("../../../assets/logo.png"); interface PaywallProps { visible: boolean; onClose: () => void; onSuccess?: () => void; /** Optional RevenueCat offering identifier. If not provided, uses the default offering. */ offeringId?: string; } const FEATURES = [ { icon: "layers" as const, title: "All Premium Packs", description: "Unlock every intimate collection", }, { icon: "sparkles" as const, title: "New Content Weekly", description: "Fresh questions added regularly", }, { icon: "heart" as const, title: "Shared Access", description: "Your partner gets Pro too", }, ]; export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProps) { const { isAnonymous } = useAuthStore(); const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => { Events.paywallClosed("paywall", reason); onClose(); }; const { offerings, isLoadingOfferings, isPurchasing, error, fetchOfferings, purchasePackage, restorePurchases, } = useSubscriptionStore(); const [selectedPackage, setSelectedPackage] = useState( null ); const [showGuestConfirm, setShowGuestConfirm] = useState(false); const [guestConfirmed, setGuestConfirmed] = useState(false); useEffect(() => { if (visible) { fetchOfferings(offeringId); Events.paywallShown("paywall"); setGuestConfirmed(false); setShowGuestConfirm(false); } }, [visible, offeringId, fetchOfferings]); useEffect(() => { // Auto-select annual package as default (better value) if (offerings?.availablePackages) { const annual = offerings.availablePackages.find( (p) => p.packageType === "ANNUAL" ); setSelectedPackage(annual || offerings.availablePackages[0]); } }, [offerings]); const doPurchase = async () => { if (!selectedPackage) return; const packageType = selectedPackage.packageType || "unknown"; Events.purchaseInitiated(packageType); const result = await purchasePackage(selectedPackage); if (result.success) { Events.purchaseCompleted(packageType); onSuccess?.(); handleClose("success"); return; } if (result.cancelled) { Events.purchaseCancelled(packageType, result.errorCode); return; } Events.purchaseFailed(packageType, result.errorCode, result.errorMessage); }; const handlePurchase = async () => { if (!selectedPackage) return; if (isAnonymous && !guestConfirmed) { setShowGuestConfirm(true); return; } await doPurchase(); }; const handleRestore = async () => { const restored = await restorePurchases(); if (restored) { Events.purchaseRestored(); onSuccess?.(); handleClose("restore"); } }; const formatPrice = (pkg: PurchasesPackage) => { const price = pkg.product.priceString; if (pkg.packageType === "ANNUAL") { const monthlyPrice = (pkg.product.price / 12).toFixed(2); return `${price}/year (${pkg.product.currencyCode} ${monthlyPrice}/mo)`; } return `${price}/month`; }; const calculateSavings = () => { if (!offerings?.availablePackages) return null; const monthly = offerings.availablePackages.find( (p) => p.packageType === "MONTHLY" ); const annual = offerings.availablePackages.find( (p) => p.packageType === "ANNUAL" ); if (monthly && annual) { const yearlyFromMonthly = monthly.product.price * 12; const savings = Math.round( (1 - annual.product.price / yearlyFromMonthly) * 100 ); return savings; } return null; }; const savings = calculateSavings(); return ( handleClose("system")} > {/* Gold accent line at top */} {/* Close Button */} handleClose("dismiss")} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} > {/* Header */} {/* Sauci logo */} Sauci Pro Unlock the full intimate experience {/* Features */} {FEATURES.map((feature) => ( {feature.title} {feature.description} ))} {/* Packages */} {isLoadingOfferings ? ( Loading options... ) : offerings?.availablePackages ? ( <> {/* Annual package - hero card */} {offerings.availablePackages .filter((p) => p.packageType === "ANNUAL") .map((pkg) => { const isSelected = selectedPackage?.identifier === pkg.identifier; return ( setSelectedPackage(pkg)} style={[ styles.packageCard, styles.packageCardAnnual, isSelected && styles.packageCardSelected, ]} > {/* Subtle gold tint background */} {/* Best value badge */} {savings && savings > 0 && ( SAVE {savings}% )} {isSelected && } Annual BEST VALUE {formatPrice(pkg)} ); })} {/* Monthly package */} {offerings.availablePackages .filter((p) => p.packageType === "MONTHLY") .map((pkg) => { const isSelected = selectedPackage?.identifier === pkg.identifier; return ( setSelectedPackage(pkg)} style={[ styles.packageCard, isSelected && styles.packageCardSelected, ]} > {isSelected && } Monthly {formatPrice(pkg)} ); })} ) : ( Unable to load subscription options. {error && ( {error} )} fetchOfferings(offeringId)} > Retry )} {/* Error Message */} {error && ( {error} )} {/* Purchase Button */} {isPurchasing ? ( ) : ( Unlock Pro )} Restore Purchases {/* Legal */} Payment will be charged to your Apple ID account at confirmation. Subscription automatically renews unless cancelled at least 24 hours before the end of the current period. Linking.openURL("https://sauci.app/terms")} > Terms • Linking.openURL("https://sauci.app/privacy")} > Privacy {/* Guest confirmation modal */} {showGuestConfirm && ( setShowGuestConfirm(false)} /> setShowGuestConfirm(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} style={styles.guestConfirmClose} > Save your account first? Unsaved accounts can't be recovered if you delete the app or switch devices. Saving protects your Pro access. { setShowGuestConfirm(false); handleClose("dismiss"); router.push("/(app)/settings/save-account" as any); }} > Save account { setGuestConfirmed(true); setShowGuestConfirm(false); await doPurchase(); }} > Continue anyway )} ); } const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.85)", justifyContent: "flex-end", }, content: { backgroundColor: colors.background, borderTopLeftRadius: radius.xxl, borderTopRightRadius: radius.xxl, maxHeight: "92%", overflow: 'hidden', }, topAccent: { position: 'absolute', top: 0, left: 0, right: 0, height: 3, borderTopLeftRadius: radius.xxl, borderTopRightRadius: radius.xxl, }, scrollContent: { padding: spacing.lg, paddingTop: spacing.xxl, paddingBottom: Platform.OS === "ios" ? 44 : spacing.xl, }, closeButton: { position: "absolute", top: spacing.sm, right: spacing.sm, width: 36, height: 36, borderRadius: 18, backgroundColor: colors.backgroundLight, justifyContent: "center", alignItems: "center", zIndex: 10, }, // Header header: { alignItems: "center", marginBottom: spacing.xl, }, logoContainer: { width: 88, height: 88, justifyContent: "center", alignItems: "center", marginBottom: spacing.lg, }, logo: { width: 88, height: 88, }, title: { ...typography.largeTitle, color: colors.text, marginBottom: spacing.xs, }, subtitle: { ...typography.body, color: colors.textSecondary, textAlign: "center", }, // Features features: { marginBottom: spacing.xl, gap: spacing.sm, }, featureRow: { flexDirection: "row", alignItems: "center", paddingVertical: spacing.sm, paddingHorizontal: spacing.md, backgroundColor: colors.backgroundLight, borderRadius: radius.md, }, featureIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.premium.goldLight, justifyContent: "center", alignItems: "center", marginRight: spacing.md, }, featureText: { flex: 1, }, featureTitle: { ...typography.headline, color: colors.text, }, featureDescription: { ...typography.subhead, color: colors.textSecondary, marginTop: 2, }, // Packages packages: { marginBottom: spacing.lg, gap: spacing.md, }, loadingContainer: { alignItems: "center", padding: spacing.xl, }, loadingText: { ...typography.subhead, color: colors.textSecondary, marginTop: spacing.md, }, packageCard: { position: "relative", backgroundColor: colors.backgroundLight, borderRadius: radius.lg, borderWidth: 2, borderColor: colors.border, padding: spacing.md, overflow: "hidden", }, packageCardSelected: { borderColor: colors.premium.gold, }, packageCardAnnual: { borderColor: colors.premium.goldLight, }, annualBackground: { ...StyleSheet.absoluteFillObject, backgroundColor: colors.premium.goldLight, opacity: 0.3, }, savingsBadge: { position: "absolute", top: 0, right: spacing.md, paddingHorizontal: spacing.sm, paddingVertical: 4, borderBottomLeftRadius: radius.sm, borderBottomRightRadius: radius.sm, overflow: 'hidden', }, savingsText: { ...typography.caption2, color: colors.background, fontWeight: "800", letterSpacing: 0.5, }, packageContent: { flexDirection: "row", alignItems: "center", }, radioOuter: { width: 24, height: 24, borderRadius: 12, borderWidth: 2, borderColor: colors.border, justifyContent: "center", alignItems: "center", marginRight: spacing.md, }, radioOuterSelected: { borderColor: colors.premium.gold, }, radioInner: { width: 12, height: 12, borderRadius: 6, backgroundColor: colors.premium.gold, }, packageInfo: { flex: 1, }, packageHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, }, packageTitle: { ...typography.headline, color: colors.text, }, packageTitleAnnual: { ...typography.headline, color: colors.premium.champagne, }, recommendedBadge: { flexDirection: 'row', alignItems: 'center', gap: 4, backgroundColor: colors.premium.goldLight, paddingHorizontal: spacing.sm, paddingVertical: 2, borderRadius: radius.xs, }, recommendedText: { ...typography.caption2, color: colors.premium.gold, fontWeight: '700', letterSpacing: 0.3, }, packagePrice: { ...typography.subhead, color: colors.textSecondary, marginTop: 2, }, packagePriceAnnual: { ...typography.subhead, color: colors.premium.champagne, marginTop: 2, }, // Error states errorContainer: { alignItems: "center", padding: spacing.lg, }, errorText: { ...typography.subhead, color: colors.textSecondary, textAlign: "center", marginBottom: spacing.md, }, retryButton: { backgroundColor: colors.premium.goldLight, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, borderRadius: radius.md, }, retryButtonText: { ...typography.subhead, color: colors.premium.gold, fontWeight: "600", }, errorBanner: { backgroundColor: colors.errorLight, padding: spacing.md, borderRadius: radius.md, marginBottom: spacing.md, }, errorBannerText: { ...typography.subhead, color: colors.error, textAlign: "center", }, // Actions actions: { marginBottom: spacing.lg, }, purchaseButton: { borderRadius: radius.lg, overflow: "hidden", ...shadows.lg, shadowColor: colors.premium.gold, shadowOpacity: 0.4, }, purchaseButtonDisabled: { opacity: 0.5, shadowOpacity: 0, }, purchaseButtonLoading: { paddingVertical: spacing.md, alignItems: "center", backgroundColor: colors.premium.gold, }, purchaseButtonGradient: { paddingVertical: spacing.md, alignItems: "center", justifyContent: 'center', }, purchaseButtonText: { ...typography.headline, color: colors.background, fontWeight: '700', fontSize: 18, }, restoreButton: { alignItems: "center", marginTop: spacing.md, padding: spacing.sm, }, restoreText: { ...typography.subhead, color: colors.textSecondary, }, // Legal legal: { paddingHorizontal: spacing.sm, }, legalText: { ...typography.caption1, color: colors.textTertiary, textAlign: "center", lineHeight: 18, }, legalLinks: { flexDirection: "row", justifyContent: "center", alignItems: "center", marginTop: spacing.sm, gap: spacing.sm, }, legalLinkText: { ...typography.caption1, color: colors.textSecondary, textDecorationLine: "underline", }, legalSeparator: { ...typography.caption1, color: colors.textTertiary, }, // Guest confirm modal guestConfirmOverlay: { ...StyleSheet.absoluteFillObject, backgroundColor: "rgba(0, 0, 0, 0.8)", justifyContent: "center", alignItems: "center", padding: spacing.lg, }, guestConfirmCard: { width: "100%", maxWidth: 400, backgroundColor: colors.backgroundLight, borderRadius: radius.xl, padding: spacing.lg, borderWidth: 1, borderColor: colors.border, }, guestConfirmHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: spacing.md, }, guestConfirmIconWrapper: { width: 48, height: 48, borderRadius: 24, backgroundColor: colors.premium.goldLight, justifyContent: "center", alignItems: "center", }, guestConfirmClose: { width: 36, height: 36, borderRadius: 18, backgroundColor: colors.background, justifyContent: "center", alignItems: "center", }, guestConfirmTitle: { ...typography.title3, color: colors.text, marginBottom: spacing.sm, }, guestConfirmBody: { ...typography.body, color: colors.textSecondary, lineHeight: 22, marginBottom: spacing.lg, }, guestConfirmActions: { gap: spacing.sm, }, guestConfirmPrimary: { borderRadius: radius.lg, overflow: 'hidden', }, guestConfirmPrimaryGradient: { paddingVertical: spacing.md, alignItems: "center", }, guestConfirmPrimaryText: { ...typography.headline, color: colors.background, fontWeight: "700", }, guestConfirmSecondary: { backgroundColor: colors.background, paddingVertical: spacing.md, borderRadius: radius.lg, alignItems: "center", borderWidth: 1, borderColor: colors.border, }, guestConfirmSecondaryText: { ...typography.subhead, color: colors.textSecondary, fontWeight: "600", }, }); export default Paywall;