From 4a8f1b74f10f80d36e1a8b942556c5adf9747ab8 Mon Sep 17 00:00:00 2001 From: Swashata Ghosh Date: Thu, 9 Oct 2025 15:14:50 +0530 Subject: [PATCH 1/7] [sdk] Install the Freemius SDK and React Starter Kit --- package-lock.json | 45 ++- package.json | 5 +- src/lib/freemius.ts | 8 + src/react-starter/components/billing-form.tsx | 205 ++++++++++++ src/react-starter/components/billing-info.tsx | 67 ++++ src/react-starter/components/billing-item.tsx | 10 + .../components/billing-section.tsx | 38 +++ .../components/cancel-subscription.tsx | 291 ++++++++++++++++ .../components/checkout-provider.tsx | 126 +++++++ src/react-starter/components/combobox.tsx | 89 +++++ .../components/customer-portal.tsx | 208 ++++++++++++ .../components/paginated-list.tsx | 164 +++++++++ .../components/payment-badge.tsx | 56 ++++ src/react-starter/components/payment-icon.tsx | 12 + .../components/payment-method-update.tsx | 38 +++ .../components/payments-section.tsx | 79 +++++ src/react-starter/components/paywall.tsx | 106 ++++++ .../components/pricing-skeleton.tsx | 30 ++ .../components/pricing-table.tsx | 169 ++++++++++ .../components/primary-subscription.tsx | 43 +++ src/react-starter/components/processing.tsx | 14 + .../components/restore-purchase.tsx | 45 +++ .../components/section-heading.tsx | 17 + src/react-starter/components/subscribe.tsx | 33 ++ .../components/subscription-action.tsx | 310 ++++++++++++++++++ .../components/subscription-info.tsx | 77 +++++ src/react-starter/components/topup-table.tsx | 131 ++++++++ src/react-starter/components/topup.tsx | 43 +++ src/react-starter/hooks/checkout.tsx | 48 +++ src/react-starter/hooks/data.tsx | 162 +++++++++ src/react-starter/hooks/portal.tsx | 17 + src/react-starter/icons/card.tsx | 22 ++ src/react-starter/icons/check.tsx | 22 ++ src/react-starter/icons/document.tsx | 29 ++ src/react-starter/icons/edit.tsx | 22 ++ src/react-starter/icons/oneoff-purchase.tsx | 23 ++ src/react-starter/icons/paypal.tsx | 22 ++ src/react-starter/icons/pdf.tsx | 22 ++ src/react-starter/icons/refresh.tsx | 22 ++ src/react-starter/icons/refund.tsx | 29 ++ src/react-starter/icons/save.tsx | 22 ++ src/react-starter/icons/spinner.tsx | 18 + .../icons/subscription-initial.tsx | 23 ++ .../icons/subscription-renewal.tsx | 23 ++ src/react-starter/utils/cancellation.ts | 54 +++ src/react-starter/utils/country.ts | 259 +++++++++++++++ src/react-starter/utils/fetch.ts | 37 +++ src/react-starter/utils/formatter.ts | 57 ++++ src/react-starter/utils/locale.tsx | 291 ++++++++++++++++ src/react-starter/utils/pricing-ops.ts | 58 ++++ 50 files changed, 3736 insertions(+), 5 deletions(-) create mode 100644 src/lib/freemius.ts create mode 100644 src/react-starter/components/billing-form.tsx create mode 100644 src/react-starter/components/billing-info.tsx create mode 100644 src/react-starter/components/billing-item.tsx create mode 100644 src/react-starter/components/billing-section.tsx create mode 100644 src/react-starter/components/cancel-subscription.tsx create mode 100644 src/react-starter/components/checkout-provider.tsx create mode 100644 src/react-starter/components/combobox.tsx create mode 100644 src/react-starter/components/customer-portal.tsx create mode 100644 src/react-starter/components/paginated-list.tsx create mode 100644 src/react-starter/components/payment-badge.tsx create mode 100644 src/react-starter/components/payment-icon.tsx create mode 100644 src/react-starter/components/payment-method-update.tsx create mode 100644 src/react-starter/components/payments-section.tsx create mode 100644 src/react-starter/components/paywall.tsx create mode 100644 src/react-starter/components/pricing-skeleton.tsx create mode 100644 src/react-starter/components/pricing-table.tsx create mode 100644 src/react-starter/components/primary-subscription.tsx create mode 100644 src/react-starter/components/processing.tsx create mode 100644 src/react-starter/components/restore-purchase.tsx create mode 100644 src/react-starter/components/section-heading.tsx create mode 100644 src/react-starter/components/subscribe.tsx create mode 100644 src/react-starter/components/subscription-action.tsx create mode 100644 src/react-starter/components/subscription-info.tsx create mode 100644 src/react-starter/components/topup-table.tsx create mode 100644 src/react-starter/components/topup.tsx create mode 100644 src/react-starter/hooks/checkout.tsx create mode 100644 src/react-starter/hooks/data.tsx create mode 100644 src/react-starter/hooks/portal.tsx create mode 100644 src/react-starter/icons/card.tsx create mode 100644 src/react-starter/icons/check.tsx create mode 100644 src/react-starter/icons/document.tsx create mode 100644 src/react-starter/icons/edit.tsx create mode 100644 src/react-starter/icons/oneoff-purchase.tsx create mode 100644 src/react-starter/icons/paypal.tsx create mode 100644 src/react-starter/icons/pdf.tsx create mode 100644 src/react-starter/icons/refresh.tsx create mode 100644 src/react-starter/icons/refund.tsx create mode 100644 src/react-starter/icons/save.tsx create mode 100644 src/react-starter/icons/spinner.tsx create mode 100644 src/react-starter/icons/subscription-initial.tsx create mode 100644 src/react-starter/icons/subscription-renewal.tsx create mode 100644 src/react-starter/utils/cancellation.ts create mode 100644 src/react-starter/utils/country.ts create mode 100644 src/react-starter/utils/fetch.ts create mode 100644 src/react-starter/utils/formatter.ts create mode 100644 src/react-starter/utils/locale.tsx create mode 100644 src/react-starter/utils/pricing-ops.ts diff --git a/package-lock.json b/package-lock.json index 4c4c4df..75675d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@freemius/checkout": "^1.3.1", + "@freemius/sdk": "^0.0.6", "@icons-pack/react-simple-icons": "^13.7.0", "@prisma/client": "^6.13.0", "@prisma/extension-accelerate": "^2.0.2", @@ -61,7 +63,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "use-stick-to-bottom": "^1.1.1", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^4.1.12" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1129,6 +1132,25 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@freemius/checkout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@freemius/checkout/-/checkout-1.3.1.tgz", + "integrity": "sha512-NS3s8aFyFcBp2/3crhKSfUo0tAHeuBqLqJWkovGa7nmTrAt5Qk1450Klu26Hfe+fBfJc6IpYhaPNwClF4U83fA==", + "license": "MIT" + }, + "node_modules/@freemius/sdk": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@freemius/sdk/-/sdk-0.0.6.tgz", + "integrity": "sha512-7hO4uAWRMUl/vpy8mu8AoN9PxRPCo5Df6c42REY7JnaY/7iPYpw3VH6Q6p07dcF0uw33GsVuSj3yG6IRf/+7yA==", + "license": "MIT", + "dependencies": { + "openapi-fetch": "^0.14.0" + }, + "peerDependencies": { + "@freemius/checkout": "^1.3.1", + "zod": "^4.0.0" + } + }, "node_modules/@hexagon/base64": { "version": "1.1.28", "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", @@ -8324,6 +8346,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.1.tgz", + "integrity": "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "license": "MIT" + }, "node_modules/ora": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", @@ -10731,9 +10768,9 @@ } }, "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 79d6550..9b77c9f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@freemius/checkout": "^1.3.1", + "@freemius/sdk": "^0.0.6", "@icons-pack/react-simple-icons": "^13.7.0", "@prisma/client": "^6.13.0", "@prisma/extension-accelerate": "^2.0.2", @@ -63,7 +65,8 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "use-stick-to-bottom": "^1.1.1", - "vaul": "^1.1.2" + "vaul": "^1.1.2", + "zod": "^4.1.12" }, "peerDependencies": { "react": ">=16.8.0", diff --git a/src/lib/freemius.ts b/src/lib/freemius.ts new file mode 100644 index 0000000..b066210 --- /dev/null +++ b/src/lib/freemius.ts @@ -0,0 +1,8 @@ +import { Freemius } from '@freemius/sdk'; + +export const freemius = new Freemius({ + productId: process.env.FREEMIUS_PRODUCT_ID!, + apiKey: process.env.FREEMIUS_API_KEY!, + secretKey: process.env.FREEMIUS_SECRET_KEY!, + publicKey: process.env.FREEMIUS_PUBLIC_KEY!, +}); diff --git a/src/react-starter/components/billing-form.tsx b/src/react-starter/components/billing-form.tsx new file mode 100644 index 0000000..69548ab --- /dev/null +++ b/src/react-starter/components/billing-form.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { PortalData } from '@freemius/sdk'; +import { useLocale } from '../utils/locale'; +import { BillingRequest } from '@freemius/sdk'; +import Spinner from '../icons/spinner'; +import { Input } from '@/components/ui/input'; +import { BillingItem } from './billing-item'; +import { countriesOptions } from '../utils/country'; +import { Combobox } from './combobox'; + +export function BillingForm(props: { + billing: NonNullable; + setIsUpdating: (isUpdating: boolean) => void; + updateBilling: (billing: BillingRequest) => Promise; +}) { + const { billing, setIsUpdating, updateBilling } = props; + const [isLoading, setIsLoading] = React.useState(false); + const locale = useLocale(); + const formRef = React.useRef(null); + + // Focus the first input when the form is rendered + React.useEffect(() => { + if (formRef.current) { + const firstInput = formRef.current.querySelector('input, textarea'); + if (firstInput) { + (firstInput as HTMLInputElement).focus(); + } + } + }, []); + + // Form state + const [formData, setFormData] = React.useState({ + business_name: billing.business_name ?? '', + tax_id: billing.tax_id ?? '', + phone: billing.phone ?? '', + address_apt: billing.address_apt ?? '', + address_street: billing.address_street ?? '', + address_city: billing.address_city ?? '', + address_state: billing.address_state ?? '', + address_country_code: billing.address_country_code ?? '', + address_zip: billing.address_zip ?? '', + }); + + const handleInputChange = (field: keyof typeof formData, value: string) => { + setFormData((prev) => ({ + ...prev, + [field]: value, + })); + }; + + const onUpdate = async (event: React.FormEvent) => { + event.preventDefault(); + + try { + setIsLoading(true); + + // Create payload with only non-empty fields + const payload: BillingRequest = {}; + + if (formData.business_name.trim()) { + payload.business_name = formData.business_name.trim(); + } + if (formData.phone.trim()) { + payload.phone = formData.phone.trim(); + } + if (formData.tax_id.trim()) { + payload.tax_id = formData.tax_id.trim(); + } + if (formData.address_street.trim()) { + payload.address_street = formData.address_street.trim(); + } + if (formData.address_apt.trim()) { + payload.address_apt = formData.address_apt.trim(); + } + if (formData.address_city.trim()) { + payload.address_city = formData.address_city.trim(); + } + if (formData.address_country_code.trim()) { + payload.address_country_code = formData.address_country_code.trim(); + } + if (formData.address_state.trim()) { + payload.address_state = formData.address_state.trim(); + } + if (formData.address_zip.trim()) { + payload.address_zip = formData.address_zip.trim(); + } + + await updateBilling(payload); + setIsUpdating(false); + } catch (error) { + console.error('Failed to update billing information:', error); + if (error instanceof Error && typeof window !== 'undefined') { + window.alert('Failed to update billing information: ' + error.message); + } + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+ handleInputChange('business_name', e.target.value)} + placeholder="Enter business name" + /> + } + /> + handleInputChange('phone', e.target.value)} + placeholder="Enter phone number" + /> + } + /> + + handleInputChange('tax_id', e.target.value)} + placeholder="Enter tax ID" + /> + } + /> + + + handleInputChange('address_street', e.target.value)} + placeholder="Street address" + /> + handleInputChange('address_apt', e.target.value)} + placeholder="Apartment, suite, etc. (optional)" + /> +
+ handleInputChange('address_city', e.target.value)} + placeholder="City" + className="flex-1" + /> + handleInputChange('address_state', e.target.value)} + placeholder="State" + className="flex-1" + /> +
+
+ handleInputChange('address_zip', e.target.value)} + placeholder="ZIP/Postal code" + className="flex-1" + /> + handleInputChange('address_country_code', value)} + placeholder="Select country" + searchPlaceholder="Search countries..." + emptyMessage="No country found." + className="flex-1" + /> +
+
+ } + /> + + +
+ + +
+
+ ); +} diff --git a/src/react-starter/components/billing-info.tsx b/src/react-starter/components/billing-info.tsx new file mode 100644 index 0000000..ba2031d --- /dev/null +++ b/src/react-starter/components/billing-info.tsx @@ -0,0 +1,67 @@ +'use client'; + +import * as React from 'react'; +import { PortalData } from '@freemius/sdk'; +import { useLocale } from '../utils/locale'; +import { BillingItem } from './billing-item'; +import { Button } from '@/components/ui/button'; + +export function BillingInfo(props: { + billing: NonNullable; + user: NonNullable; + setIsUpdating: (isUpdating: boolean) => void; +}) { + const { billing, user, setIsUpdating } = props; + const locale = useLocale(); + + const address: string[] = []; + + if (billing.address_street) { + address.push(billing.address_street); + } + if (billing.address_apt) { + address.push(billing.address_apt); + } + + address.push( + `${billing.address_city ? `${billing.address_city}, ` : ''}${billing.address_state ?? ''} ${billing.address_zip ?? ''}`, + billing.address_country ? billing.address_country : '' + ); + + return ( + <> +
+ + {billing.phone ?? ''}} + /> + {billing.tax_id ?? ''}} + /> + + {address.map((item) => ( +

{item}

+ ))} +
+ } + /> + + #{user.id}} + /> + + +
+ +
+ + ); +} diff --git a/src/react-starter/components/billing-item.tsx b/src/react-starter/components/billing-item.tsx new file mode 100644 index 0000000..3806f1a --- /dev/null +++ b/src/react-starter/components/billing-item.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; + +export function BillingItem(props: { label: React.ReactNode; value: React.ReactNode }) { + return ( +
+
{props.label}
+
{props.value}
+
+ ); +} diff --git a/src/react-starter/components/billing-section.tsx b/src/react-starter/components/billing-section.tsx new file mode 100644 index 0000000..abfc95d --- /dev/null +++ b/src/react-starter/components/billing-section.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { PortalData } from '@freemius/sdk'; +import { SectionHeading } from './section-heading'; +import { useLocale } from '../utils/locale'; +import { BillingForm } from './billing-form'; +import { BillingInfo } from './billing-info'; +import { BillingUpdatePayload } from '@freemius/sdk'; +import { usePortalAction } from '../hooks/data'; + +export function BillingSection(props: { + billing: NonNullable; + user: NonNullable; +}) { + const [isUpdating, setIsUpdating] = React.useState(false); + const locale = useLocale(); + const [billing, setBilling] = React.useState>({ + ...props.billing, + }); + + const { execute } = usePortalAction(props.billing.updateUrl); + + const updateBilling = async (billing: BillingUpdatePayload) => { + const updatedBilling = await execute(billing); + setBilling(updatedBilling); + }; + + return ( +
+ {locale.portal.billing.title()} + + {isUpdating ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/react-starter/components/cancel-subscription.tsx b/src/react-starter/components/cancel-subscription.tsx new file mode 100644 index 0000000..86c3542 --- /dev/null +++ b/src/react-starter/components/cancel-subscription.tsx @@ -0,0 +1,291 @@ +'use client'; + +import * as React from 'react'; +import { + type PortalData, + type ApplyRenewalCouponRequest, + idToString, + SubscriptionCancellationRequest, +} from '@freemius/sdk'; +import { useLocale } from '../utils/locale'; +import { formatCurrency, getDaysLeft } from '../utils/formatter'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { getRenewalCouponDiscounts } from '../utils/pricing-ops'; +import { getCancellationReasons } from '../utils/cancellation'; +import { usePortalAction } from '../hooks/data'; +import Spinner from '../icons/spinner'; + +export function CancelSubscription(props: { + subscription: NonNullable; + onClose: () => void; + cancellationCoupons?: PortalData['cancellationCoupons']; + afterCancel?: () => void; + afterCouponApplied?: () => void; +}) { + const { onClose, subscription, cancellationCoupons, afterCancel, afterCouponApplied } = props; + const [cancellationState, setCancellationState] = React.useState<'confirm' | 'coupon' | 'reason'>('confirm'); + + return ( +
+ {(function () { + switch (cancellationState) { + case 'confirm': + return ( + { + if (cancellationCoupons?.length && subscription.applyRenewalCancellationCouponUrl) { + setCancellationState('coupon'); + } else { + setCancellationState('reason'); + } + }} + /> + ); + case 'coupon': + return ( + setCancellationState('reason')} + coupons={props.cancellationCoupons!} + afterCouponApplied={afterCouponApplied} + /> + ); + case 'reason': + return ( + + ); + default: + return null; + } + })()} +
+ ); +} + +function ConfirmSubscriptionCancellation(props: { + subscription: NonNullable; + onClose: () => void; + onCancel: () => void; +}) { + const { onClose, subscription, onCancel } = props; + const locale = useLocale(); + + const daysLeft = + subscription.isTrial && subscription.trialEnds + ? getDaysLeft(subscription.trialEnds) + : getDaysLeft(subscription.renewalDate); + + return ( + <> +

{locale.portal.cancelSubscription.title.confirm()}

+ +
+ {(subscription.isTrial + ? locale.portal.cancelSubscription.message.trial.paragraphs(daysLeft, null) + : locale.portal.cancelSubscription.message.regular.paragraphs(daysLeft) + ).map((p, i) => ( +

+ {p} +

+ ))} +
+ +
+ + + +
+ + ); +} + +function ApplyCancellationCoupon(props: { + subscription: NonNullable; + onClose: () => void; + onCancel: () => void; + coupons: PortalData['cancellationCoupons']; + afterCouponApplied?: () => void; +}) { + const { onClose, subscription, onCancel, coupons } = props; + const locale = useLocale(); + const couponsAvailable = coupons && coupons.length > 0; + const { execute, loading } = usePortalAction( + subscription.applyRenewalCancellationCouponUrl! + ); + + // protect against no coupons + if (!couponsAvailable) { + throw new Error('No coupons available'); + } + + const coupon = coupons[0]; + const { dollarOff, percentageOff } = getRenewalCouponDiscounts( + coupon, + subscription.renewalAmount, + subscription.currency, + subscription.paymentMethod === 'paypal' + ); + + const isPercentage = percentageOff > dollarOff; + const discountLabel = isPercentage + ? `${percentageOff}%` + : formatCurrency(dollarOff, subscription.currency, locale.code); + + return ( + <> +
+
+
{discountLabel}
+
+ {locale.portal.cancelSubscription.message.coupon.off()} +
+
+ +

+ {locale.portal.cancelSubscription.title.coupon(discountLabel)} +

+ +
+ {locale.portal.cancelSubscription.message.coupon + .paragraphs(coupon.has_renewals_discount ?? false, discountLabel) + .map((p, i) => ( +

+ {p} +

+ ))} +
+
+ +
+ + + + + +
+ + ); +} + +function AskReasonAndCancel(props: { + subscription: NonNullable; + onClose: () => void; + afterCancel?: () => void; +}) { + const { onClose, subscription, afterCancel } = props; + const locale = useLocale(); + const [reasons, setReasons] = React.useState>([]); + const [feedback, setFeedback] = React.useState(''); + const { execute, loading } = usePortalAction(subscription.cancelRenewalUrl); + + const hasTrial = subscription.isTrial; + const reasonOptions = React.useMemo(() => getCancellationReasons(hasTrial), [hasTrial]); + + return ( + <> +

{locale.portal.cancelSubscription.title.reason()}

+ +
+ {locale.portal.cancelSubscription.message.reason.paragraphs().map((p, i) => ( +

+ {p} +

+ ))} +
+ +
+
+ +
+ {reasonOptions.map((r) => ( +
+ { + if (checked) { + setReasons((prev) => [...prev, r.id]); + } else { + setReasons((prev) => prev.filter((id) => id !== r.id)); + } + }} + /> + +
+ ))} +
+
+ +
+ +