Skip to content

Commit 4fe9bd9

Browse files
committed
feat: introduce Polar billing utilities and enhance checkout URLs
- Added a new CLI command for seeding Polar billing plans into the database. - Updated checkout success and cancel URLs in .env.example and vars.example.yml for improved routing. - Enhanced the NotFound component to report route not found events for better observability. - Implemented success alerts in PlanSettingsSection to notify users of subscription updates with confetti celebration.
1 parent 8af792a commit 4fe9bd9

File tree

16 files changed

+420
-37
lines changed

16 files changed

+420
-37
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ POLAR_PRODUCT_BUSINESS_MONTHLY="prod_..."
3333
POLAR_PRODUCT_CREDITS_50="prod_..."
3434
POLAR_PRODUCT_CREDITS_100="prod_..."
3535
PUBLIC_URL="http://localhost:3000"
36-
CHECKOUT_SUCCESS_URL="http://localhost:3000/dashboard/billing/success"
37-
CHECKOUT_CANCEL_URL="http://localhost:3000/dashboard/billing"
36+
CHECKOUT_SUCCESS_URL="http://localhost:3000/billing/success"
37+
CHECKOUT_CANCEL_URL="http://localhost:3000/dashboard"
3838
VITE_ENTERPRISE_DEMO_URL="https://calendly.com/your-team/demo"
3939
VITE_POLAR_PRODUCT_CREDITS_50="prod_..."
4040
VITE_POLAR_PRODUCT_CREDITS_100="prod_..."

cli/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ stack (Postgres, Redis, MinIO, Meilisearch, Mailhog) so the app is ready to go.
2424
- `pnpm run ex0 -- gc` – prune Docker images/volumes managed by the project.
2525
- `pnpm run ex0 -- testdata <subcommand>` – generate or clear demo data (see
2626
interactive prompts for choices).
27+
- `pnpm run ex0 -- polar seed-plans` – upsert billing plans from Polar/config into
28+
the database after plan changes.
2729

2830
### Deployments
2931

cli/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,21 @@ const testdataCommand = defineCommand({
643643
}
644644
})
645645

646+
const polarSeedPlansCommand = defineCommand({
647+
meta: { name: 'seed-plans', description: 'Upsert Polar billing plans into the database' },
648+
async run() {
649+
runCommand('node --import tsx/loader cli/scripts/seed-plans.ts', 'Seed Polar plans')
650+
}
651+
})
652+
653+
const polarCommand = defineCommand({
654+
meta: { name: 'polar', description: 'Polar billing utilities' },
655+
subCommands: { 'seed-plans': polarSeedPlansCommand },
656+
async run() {
657+
await polarSeedPlansCommand.run({ args: {}, options: {}, rawArgs: [] })
658+
}
659+
})
660+
646661
const main = defineCommand({
647662
meta: { name: 'cli', version: '2.0.0', description: 'Project management CLI (Compose-first, Vault-aware)' },
648663
subCommands: {
@@ -663,6 +678,7 @@ const main = defineCommand({
663678
// tunnels/services
664679
tunnel: tunnelCommand,
665680
services: servicesCommand,
681+
polar: polarCommand,
666682
// vault helpers
667683
vault: vaultCommand
668684
}

cli/scripts/seed-plans.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import 'dotenv/config'
2+
3+
import { seedPlans } from '../../src/server/seed-plans'
4+
5+
async function main() {
6+
try {
7+
await seedPlans()
8+
console.log('Plans seeded successfully')
9+
} catch (error) {
10+
console.error('Failed to seed plans', error)
11+
process.exitCode = 1
12+
}
13+
}
14+
15+
void main()

infra/ansible/group_vars/constructa/vars.example.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ constructa_env:
9797
POLAR_PRODUCT_BUSINESS_MONTHLY: ''
9898
POLAR_PRODUCT_CREDITS_50: ''
9999
POLAR_PRODUCT_CREDITS_100: ''
100+
CHECKOUT_SUCCESS_URL: 'https://${APP_HOSTNAME}/billing/success'
101+
CHECKOUT_CANCEL_URL: 'https://${APP_HOSTNAME}/dashboard'
100102

101103
SENTRY_DSN: ''
102104
VITE_SENTRY_DSN: ''

src/components/NotFound.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
import { Link } from "@tanstack/react-router"
1+
import { useEffect } from "react";
2+
import { Link, useRouterState } from "@tanstack/react-router";
3+
import { reportRouteNotFound } from "~/lib/observability/report-not-found";
24

35
export function NotFound({ children }: { children?: any }) {
6+
const location = useRouterState({ select: (state) => state.location });
7+
8+
useEffect(() => {
9+
if (!location) return;
10+
void reportRouteNotFound({
11+
pathname: location.pathname,
12+
search: typeof window !== "undefined" ? window.location.search : null,
13+
href: typeof window !== "undefined" ? window.location.href : location.href,
14+
});
15+
}, [location?.href, location?.pathname]);
16+
417
return (
518
<div className="space-y-2 p-2">
619
<div className="text-gray-600 dark:text-gray-400">
@@ -21,5 +34,5 @@ export function NotFound({ children }: { children?: any }) {
2134
</Link>
2235
</p>
2336
</div>
24-
)
37+
);
2538
}

src/components/settings/sections/PlanSettings.tsx

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,69 @@
11
import * as React from 'react';
2+
import { useQueryClient } from '@tanstack/react-query';
3+
import { useRouter, useRouterState } from '@tanstack/react-router';
4+
import { motion } from 'framer-motion';
5+
import { CheckCircle2, X } from 'lucide-react';
6+
27
import { CreditMeter } from '~/components/billing/CreditMeter';
38
import { EnterpriseCTA } from '~/components/billing/EnterpriseCTA';
49
import { PlanCard } from '~/components/billing/PlanCard';
10+
import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert';
511
import { Button } from '~/components/ui/button';
612
import { useBillingInfo, useOpenPortal, useStartCheckout } from '~/hooks/useBilling';
13+
import { isClient } from '~/lib/environment';
714

815
export function PlanSettingsSection() {
916
const { data, isPending, error } = useBillingInfo();
1017
const startCheckout = useStartCheckout();
1118
const openPortal = useOpenPortal();
19+
const queryClient = useQueryClient();
20+
const router = useRouter();
21+
const location = useRouterState({ select: (state) => state.location });
22+
const search = (location.search as Record<string, unknown>) ?? {};
23+
const billingStatus = typeof search.billingStatus === 'string' ? (search.billingStatus as string) : null;
24+
const showSuccess = billingStatus === 'success';
25+
26+
const [alertVisible, setAlertVisible] = React.useState(showSuccess);
27+
const [confettiActive, setConfettiActive] = React.useState(false);
28+
29+
const clearBillingStatusParam = React.useCallback(() => {
30+
if (!isClient) return;
31+
const params = new URLSearchParams(window.location.search);
32+
params.delete('billingStatus');
33+
const query = params.toString();
34+
const href = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash ?? ''}`;
35+
void router.navigate({ href, replace: true });
36+
}, [router]);
37+
38+
const dismissSuccessAlert = React.useCallback(() => {
39+
setAlertVisible(false);
40+
clearBillingStatusParam();
41+
}, [clearBillingStatusParam]);
42+
43+
React.useEffect(() => {
44+
if (!showSuccess) return;
45+
setAlertVisible(true);
46+
void queryClient.invalidateQueries({ queryKey: ['billingInfo'] });
47+
if (!isClient) return;
48+
setConfettiActive(true);
49+
const timeout = window.setTimeout(() => setConfettiActive(false), 2000);
50+
return () => window.clearTimeout(timeout);
51+
}, [showSuccess, queryClient]);
52+
53+
React.useEffect(() => {
54+
if (!showSuccess || !alertVisible) return;
55+
if (!isClient) return;
56+
const timeout = window.setTimeout(() => {
57+
dismissSuccessAlert();
58+
}, 6000);
59+
return () => window.clearTimeout(timeout);
60+
}, [showSuccess, alertVisible, dismissSuccessAlert]);
61+
62+
React.useEffect(() => {
63+
if (!showSuccess) {
64+
setAlertVisible(false);
65+
}
66+
}, [showSuccess]);
1267

1368
if (isPending) {
1469
return <div className="text-sm text-muted-foreground">Loading plan details…</div>;
@@ -47,6 +102,10 @@ export function PlanSettingsSection() {
47102

48103
return (
49104
<div className="space-y-6">
105+
{alertVisible && (
106+
<SuccessCelebration confettiActive={confettiActive} onDismiss={dismissSuccessAlert} />
107+
)}
108+
50109
<CreditMeter
51110
allotment={data.credits.monthlyAllotment}
52111
used={data.credits.allotmentUsed}
@@ -123,3 +182,79 @@ export function PlanSettingsSection() {
123182
</div>
124183
);
125184
}
185+
186+
const CONFETTI_COLORS = ['#34d399', '#22d3ee', '#6366f1', '#f97316', '#facc15'];
187+
188+
function SuccessCelebration({
189+
confettiActive,
190+
onDismiss,
191+
}: {
192+
readonly confettiActive: boolean;
193+
readonly onDismiss: () => void;
194+
}) {
195+
return (
196+
<div className="relative overflow-hidden">
197+
<Alert variant="success" className="pr-12">
198+
<CheckCircle2 className="text-emerald-600 dark:text-emerald-200" />
199+
<AlertTitle>Subscription updated</AlertTitle>
200+
<AlertDescription>
201+
Your plan has been refreshed. Give it a moment if credits take a few seconds to sync.
202+
</AlertDescription>
203+
<button
204+
type="button"
205+
onClick={onDismiss}
206+
className="absolute right-4 top-4 rounded-full p-1 text-emerald-900/60 transition hover:text-emerald-900 dark:text-emerald-50/60 dark:hover:text-emerald-50"
207+
>
208+
<X className="h-4 w-4" />
209+
<span className="sr-only">Dismiss success message</span>
210+
</button>
211+
</Alert>
212+
<ConfettiBurst active={confettiActive} />
213+
</div>
214+
);
215+
}
216+
217+
function ConfettiBurst({ active }: { readonly active: boolean }) {
218+
const pieces = React.useMemo(
219+
() =>
220+
Array.from({ length: 28 }).map((_, index) => ({
221+
id: index,
222+
x: (Math.random() - 0.5) * 260,
223+
y: Math.random() * 180 + 40,
224+
rotate: Math.random() * 360,
225+
delay: Math.random() * 0.3,
226+
duration: 1.1 + Math.random() * 0.6,
227+
color: CONFETTI_COLORS[index % CONFETTI_COLORS.length],
228+
})),
229+
[]
230+
);
231+
232+
if (!active || !isClient) {
233+
return null;
234+
}
235+
236+
return (
237+
<div className="pointer-events-none absolute inset-0 overflow-hidden">
238+
{pieces.map((piece) => (
239+
<motion.span
240+
key={piece.id}
241+
className="absolute h-2 w-1 rounded-full"
242+
style={{
243+
backgroundColor: piece.color,
244+
left: '50%',
245+
top: '0%',
246+
}}
247+
initial={{ opacity: 0, x: 0, y: 0, scale: 0.75 }}
248+
animate={{
249+
opacity: [0, 1, 1, 0],
250+
x: piece.x,
251+
y: piece.y,
252+
rotate: piece.rotate,
253+
scale: 1,
254+
}}
255+
transition={{ duration: piece.duration, delay: piece.delay, ease: 'easeOut' }}
256+
/>
257+
))}
258+
</div>
259+
);
260+
}

src/components/ui/alert.tsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import * as React from "react";
2+
import { cva, type VariantProps } from "class-variance-authority";
3+
4+
import { cn } from "~/lib/utils";
5+
6+
const alertVariants = cva(
7+
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:size-5 [&>svg]:text-foreground",
8+
{
9+
variants: {
10+
variant: {
11+
default: "bg-background text-foreground",
12+
success:
13+
"border-emerald-500/40 bg-emerald-500/10 text-emerald-900 dark:border-emerald-400/40 dark:bg-emerald-400/10 dark:text-emerald-100",
14+
destructive:
15+
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
16+
warning:
17+
"border-amber-500/40 bg-amber-500/10 text-amber-900 dark:border-amber-400/40 dark:bg-amber-400/10 dark:text-amber-50",
18+
subtle: "border-border/60 text-muted-foreground",
19+
},
20+
},
21+
defaultVariants: {
22+
variant: "default",
23+
},
24+
}
25+
);
26+
27+
const Alert = React.forwardRef<
28+
HTMLDivElement,
29+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
30+
>(({ className, variant, ...props }, ref) => (
31+
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
32+
));
33+
Alert.displayName = "Alert";
34+
35+
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
36+
({ className, ...props }, ref) => (
37+
<h5 ref={ref} className={cn("mb-1 font-semibold leading-none tracking-tight", className)} {...props} />
38+
)
39+
);
40+
AlertTitle.displayName = "AlertTitle";
41+
42+
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
43+
({ className, ...props }, ref) => (
44+
<div ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
45+
)
46+
);
47+
AlertDescription.displayName = "AlertDescription";
48+
49+
export { Alert, AlertDescription, AlertTitle };

src/hooks/useBilling.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { useQuery } from '@tanstack/react-query';
33
import { useRouter } from '@tanstack/react-router';
44
import { useServerFn } from '@tanstack/react-start';
55

6-
import { CREDIT_PACK_PRODUCTS, PLANS } from '~/config/plans';
6+
import { CREDIT_PACK_PRODUCTS } from '~/config/plans';
77
import { authClient } from '~/lib/auth-client';
8+
import { isClient } from '~/lib/environment';
89
import { getBillingInfo as getBillingInfoFn } from '~/server/function/billing-info.server';
910

1011
type PolarClientResponse<T> = {
@@ -65,6 +66,16 @@ export function useStartCheckout() {
6566
throw new Error(error?.message ?? 'Checkout failed');
6667
}
6768

69+
if (isClient) {
70+
const currentHref = `${window.location.pathname}${window.location.search}${window.location.hash}`;
71+
try {
72+
window.sessionStorage.setItem('billing:returnTo', currentHref);
73+
window.sessionStorage.setItem('billing:returnTo:ts', Date.now().toString());
74+
} catch {
75+
// sessionStorage may be unavailable (Safari private mode etc.)
76+
}
77+
}
78+
6879
await router.navigate({
6980
href: data.url,
7081
replace: true,

src/lib/environment.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const isServer = typeof window === 'undefined';
2+
export const isClient = !isServer;
3+
4+
export function clientOnly<T>(fn: () => T): T | undefined {
5+
if (!isClient) return undefined;
6+
return fn();
7+
}
8+
9+
export function serverOnly<T>(fn: () => T): T | undefined {
10+
if (isClient) return undefined;
11+
return fn();
12+
}
13+
14+
export function createIsomorphicFn<T extends (...args: any[]) => any>(
15+
serverImpl: T,
16+
clientImpl: T,
17+
): T {
18+
return ((...args: Parameters<T>) => (isServer ? serverImpl : clientImpl)(...args)) as T;
19+
}

0 commit comments

Comments
 (0)