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..aec0f67 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,9 @@ "dev": "next dev -p 3002", "start": "next start", "lint": "next lint", - "build": "next build", - "typecheck": "tsc --noEmit" + "build": "prisma migrate deploy && prisma generate && next build", + "typecheck": "tsc --noEmit", + "postinstall": "prisma generate" }, "dependencies": { "@ai-sdk/react": "^2.0.9", @@ -15,6 +16,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 +66,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/prisma/schema.prisma b/prisma/schema.prisma index 484d39b..ae3fcec 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -10,8 +10,9 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") } model User { diff --git a/src/app/api/ai/route.ts b/src/app/api/ai/route.ts index 23c572e..8d18f51 100644 --- a/src/app/api/ai/route.ts +++ b/src/app/api/ai/route.ts @@ -1,6 +1,6 @@ import { headers } from 'next/headers'; import { auth } from '@/lib/auth'; -import { deductCredits } from '@/lib/user-entitlement'; +import { deductCredits, getUserEntitlement, hasCredits } from '@/lib/user-entitlement'; import { getAiResponse } from '@/lib/ai'; export async function POST(request: Request) { @@ -18,6 +18,29 @@ export async function POST(request: Request) { ); } + const entitlement = await getUserEntitlement(session.user.id); + + if (!entitlement) { + return Response.json( + { + code: 'no_active_purchase', + message: 'You do not have an active license to use this feature.', + }, + // 402 Payment Required + { status: 402 } + ); + } + + if (!(await hasCredits(session.user.id, 100))) { + return Response.json( + { + code: 'insufficient_credits', + message: 'You do not have enough credits to use this feature.', + }, + { status: 402 } + ); + } + /** * Here you would implement the AI asset generation and credit consumption logic. * For demonstration, we will just return a dummy response and deduct 100 credits. diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..4d01385 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,11 @@ +/** + * This route handles the Purchase actions and sync actions coming from the Freemius React Starter Kit. + */ +import { freemius } from '@/lib/freemius'; +import { processPurchaseInfo } from '@/lib/user-entitlement'; + +const processor = freemius.checkout.request.createProcessor({ + onPurchase: processPurchaseInfo, +}); + +export { processor as GET, processor as POST }; diff --git a/src/app/api/portal/route.ts b/src/app/api/portal/route.ts new file mode 100644 index 0000000..7bc4b8a --- /dev/null +++ b/src/app/api/portal/route.ts @@ -0,0 +1,11 @@ +import { freemius } from '@/lib/freemius'; +import { getFsUser, processPurchaseInfo } from '@/lib/user-entitlement'; + +const processor = freemius.customerPortal.request.createProcessor({ + getUser: getFsUser, + portalEndpoint: process.env.NEXT_PUBLIC_APP_URL! + '/api/portal', + isSandbox: process.env.NODE_ENV !== 'production', + onRestore: freemius.customerPortal.createRestorer(processPurchaseInfo), +}); + +export { processor as GET, processor as POST }; diff --git a/src/app/billing/page.tsx b/src/app/billing/page.tsx new file mode 100644 index 0000000..89c0049 --- /dev/null +++ b/src/app/billing/page.tsx @@ -0,0 +1,35 @@ +import AppMain, { AppContent } from '@/components/app-main'; +import { auth } from '@/lib/auth'; +import { freemius } from '@/lib/freemius'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; +import { ErrorBoundary } from '@/components/error'; +import { CustomerPortal } from '@/react-starter/components/customer-portal'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; + +export default async function Billing() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect('/login'); + } + + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + + return ( + + + + + + + + + + ); +} diff --git a/src/app/chat/ai-app.tsx b/src/app/chat/ai-app.tsx index 10cd1a7..63508f2 100644 --- a/src/app/chat/ai-app.tsx +++ b/src/app/chat/ai-app.tsx @@ -3,20 +3,28 @@ import { useState } from 'react'; import LoginModal from '@/components/login-modal'; import { AIChat } from '@/components/ai-chat'; +import { Paywall, usePaywall } from '@/react-starter/components/paywall'; export default function AiApp(props: { examples: string[] }) { const { examples } = props; const [isShowingLogin, setIsShowingLogin] = useState(false); + const { hidePaywall, showInsufficientCredits, showNoActivePurchase, state } = usePaywall(); // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleApiError = (data: any) => { if (data.code === 'unauthenticated') { setIsShowingLogin(true); + } else if (data.code === 'no_active_purchase') { + showNoActivePurchase(); + } else if (data.code === 'insufficient_credits') { + showInsufficientCredits(); } }; return ( <> + + setIsShowingLogin(false)} /> diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx index a33d6c0..5d8e953 100644 --- a/src/app/chat/page.tsx +++ b/src/app/chat/page.tsx @@ -3,15 +3,24 @@ import { auth } from '@/lib/auth'; import { headers } from 'next/headers'; import { examples } from '@/lib/ai'; import AiApp from './ai-app'; +import { freemius } from '@/lib/freemius'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; export default async function Dashboard() { const session = await auth.api.getSession({ headers: await headers(), }); + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + return ( - + + + ); } diff --git a/src/app/credits/credits.tsx b/src/app/credits/credits.tsx index 2149636..9eef0f4 100644 --- a/src/app/credits/credits.tsx +++ b/src/app/credits/credits.tsx @@ -1,15 +1,19 @@ 'use client'; +import { Topup } from '@/react-starter/components/topup'; + export default function Credits(props: { credits?: number; hasSubscription?: boolean }) { const { credits } = props; // Use Intl.NumberFormat to format the number with commas const formattedCredit = new Intl.NumberFormat().format(credits ?? 0); - return ( + const info = (

You have {formattedCredit} credits

You can purchase more credits below.

); + + return {info}; } diff --git a/src/app/credits/page.tsx b/src/app/credits/page.tsx index 15b346e..baa4c9f 100644 --- a/src/app/credits/page.tsx +++ b/src/app/credits/page.tsx @@ -5,6 +5,8 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { ErrorBoundary } from '@/components/error'; import Credits from './credits'; +import { freemius } from '@/lib/freemius'; +import AppCheckoutProvider from '@/components/app-checkout-provider'; export default async function CreditsPage() { const session = await auth.api.getSession({ @@ -17,11 +19,18 @@ export default async function CreditsPage() { const credits = await getCredits(session.user.id); + const checkout = await freemius.checkout.create({ + user: session?.user, + isSandbox: process.env.NODE_ENV !== 'production', + }); + return ( - + + + diff --git a/src/app/webhook/route.ts b/src/app/webhook/route.ts new file mode 100644 index 0000000..86e3125 --- /dev/null +++ b/src/app/webhook/route.ts @@ -0,0 +1,46 @@ +import { WebhookEventType } from '@freemius/sdk'; +import { freemius } from '@/lib/freemius'; +import { + deleteEntitlement, + renewCreditsFromWebhook, + sendRenewalFailureEmail, + syncEntitlementFromWebhook, +} from '@/lib/user-entitlement'; + +const listener = freemius.webhook.createListener(); + +const licenseEvents: WebhookEventType[] = [ + 'license.created', + 'license.extended', + 'license.shortened', + 'license.updated', + 'license.cancelled', + 'license.expired', + 'license.plan.changed', +]; + +listener.on(licenseEvents, async ({ objects: { license } }) => { + if (license && license.id) { + await syncEntitlementFromWebhook(license.id); + } +}); + +listener.on('license.extended', async ({ data }) => { + if (data.is_renewal) { + renewCreditsFromWebhook(data.license_id); + } +}); + +listener.on('license.deleted', async ({ data }) => { + await deleteEntitlement(data.license_id); + console.log('License deleted:', data.license_id); +}); + +listener.on('subscription.renewal.failed', async ({ objects: { subscription } }) => { + await sendRenewalFailureEmail(subscription); + console.log('Subscription renewal failed:', subscription); +}); + +const processor = freemius.webhook.createRequestProcessor(listener); + +export { processor as POST }; diff --git a/src/components/app-checkout-provider.tsx b/src/components/app-checkout-provider.tsx new file mode 100644 index 0000000..7f114e2 --- /dev/null +++ b/src/components/app-checkout-provider.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { CheckoutProvider } from '@/react-starter/components/checkout-provider'; +import { type CheckoutSerialized } from '@freemius/sdk'; +import * as React from 'react'; +import { toast } from 'sonner'; +import { useRouter } from 'next/navigation'; + +export default function AppCheckoutProvider(props: { children: React.ReactNode; checkout: CheckoutSerialized }) { + const router = useRouter(); + + const onAfterSync = React.useCallback(() => { + toast.success(`Your purchase was successful! Now you can continue using the app.`); + router.refresh(); + }, [router]); + + return ( + + {props.children} + + ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index fdc872d..79a75fe 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -36,6 +36,11 @@ const data = { }, ], navFooterLoggedIn: [ + { + title: 'Billing & Payments', + url: '/billing', + icon: IconReceipt, + }, { title: 'Credits & Topups', url: '/credits', 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/lib/user-entitlement.ts b/src/lib/user-entitlement.ts index 1ab7ef8..75195c8 100644 --- a/src/lib/user-entitlement.ts +++ b/src/lib/user-entitlement.ts @@ -8,10 +8,107 @@ * 3. The user also has a `credits` field that tracks the user's credits. */ import { prisma } from '@/lib/prisma'; -import { User } from '@generated/prisma'; +import { PurchaseInfo, SubscriptionEntity, UserRetriever } from '@freemius/sdk'; +import { User, UserFsEntitlement } from '@generated/prisma'; +import { freemius } from './freemius'; +import { auth } from './auth'; +import { headers } from 'next/headers'; // #region Freemius SDK Supporting Functions for User Entitlements & Webhooks +/** + * Process the purchase info and update the local database. + * + * This function is called when a purchase happens with Freemius. + */ +export async function processPurchaseInfo(fsPurchase: PurchaseInfo): Promise { + const user = await getUserByEmail(fsPurchase.email); + + if (!user) { + // User not found, cannot process the purchase. Alternately you can create a new user here. + return; + } + + const credit = await getCreditsForUserPurchase(user, fsPurchase); + + await prisma.userFsEntitlement.upsert({ + where: { + fsLicenseId: fsPurchase.licenseId, + }, + update: fsPurchase.toEntitlementRecord(), + create: fsPurchase.toEntitlementRecord({ userId: user.id }), + }); + + if (credit > 0) { + await addCredits(user.id, credit); + } +} + +/** + * Get the user's entitlement. + * + * @returns The user's active entitlement or null if the user does not have an active entitlement. + */ +export async function getUserEntitlement(userId: string): Promise { + const entitlements = await prisma.userFsEntitlement.findMany({ + where: { userId, type: 'subscription' }, + }); + + return freemius.entitlement.getActive(entitlements); +} + +/** + * Get the Freemius user for the current session. + * + * This is used by the Freemius SDK to identify the user. + * + * @returns The Freemius user or null if the user is not logged in. + */ +export const getFsUser: UserRetriever = async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const entitlement = session ? await getUserEntitlement(session.user.id) : null; + const email = session?.user.email ?? undefined; + + return freemius.entitlement.getFsUser(entitlement, email); +}; + +export async function deleteEntitlement(fsLicenseId: string): Promise { + await prisma.userFsEntitlement.delete({ where: { fsLicenseId: fsLicenseId } }); +} + +export async function syncEntitlementFromWebhook(fsLicenseId: string): Promise { + const purchaseInfo = await freemius.purchase.retrievePurchase(fsLicenseId); + if (purchaseInfo) { + await processPurchaseInfo(purchaseInfo); + } +} + +export async function renewCreditsFromWebhook(fsLicenseId: string): Promise { + const purchaseInfo = await freemius.purchase.retrievePurchase(fsLicenseId); + + if (purchaseInfo) { + const credits = getEntitledCredits(purchaseInfo); + + const entitlement = await prisma.userFsEntitlement.findUnique({ + where: { fsLicenseId }, + }); + + if (entitlement && credits > 0) { + await addCredits(entitlement.userId, credits); + } + } +} + +export async function sendRenewalFailureEmail(subscription: SubscriptionEntity): Promise { + // This is a placeholder for sending an email to the user about the renewal failure. + // You can use your preferred email service here. + console.log('Sending renewal failure email for subscription:', subscription); + // Example: await sendEmailToUser(subscription.user, 'Renewal failed', 'Your subscription renewal has failed.'); +} + //#endregion // #region Credit & Entitlement Management @@ -55,6 +152,37 @@ const resourceRecord = { topup_10000: 10000, } as const; +const pricingToResourceMap: Record = { + [process.env.FREEMIUS_PRICING_ID_STARTER!]: 'starter', + [process.env.FREEMIUS_PRICING_ID_PROFESSIONAL!]: 'professional', + [process.env.FREEMIUS_PRICING_ID_BUSINESS!]: 'business', + [process.env.FREEMIUS_PRICING_ID_TOPUP_1000!]: 'topup_1000', + [process.env.FREEMIUS_PRICING_ID_TOPUP_5000!]: 'topup_5000', + [process.env.FREEMIUS_PRICING_ID_TOPUP_10000!]: 'topup_10000', +}; + +function getEntitledCredits(fsPurchase: PurchaseInfo): number { + const credit = resourceRecord[pricingToResourceMap[fsPurchase.pricingId]] ?? 0; + + return fsPurchase.isAnnual() ? credit * 12 : credit; +} + +async function getCreditsForUserPurchase(user: User, fsPurchase: PurchaseInfo): Promise { + let credit = 0; + + const isExisting = await prisma.userFsEntitlement.findUnique({ + where: { + fsLicenseId: fsPurchase.licenseId, + }, + }); + + if (!isExisting) { + credit = getEntitledCredits(fsPurchase); + } + + return credit; +} + // #endregion // Private helpers 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)); + } + }} + /> + +
+ ))} +
+
+ +
+ +