From e1691cd017f5b330f4d7623346383f217c952aaa Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:29:46 -0400 Subject: [PATCH 01/29] add resend support v0 --- .env.example | 8 ++- README.md | 7 +- docker-compose.arm.yaml | 3 + docker-compose.build-arm.yaml | 3 + docker-compose.build-cloud.yaml | 3 + docker-compose.build.yaml | 3 + docker-compose.cloud.yaml | 3 + docker-compose.yaml | 3 + package.json | 1 + pnpm-lock.yaml | 38 +++++++++++ src/app/_app.index.tsx | 19 ++++++ src/app/_web.releases.$slug.tsx | 15 +++-- src/app/welcome.tsx | 22 +++---- src/components/feed/SubscriptionDialog.tsx | 35 ++++++---- src/components/ui/responsive-dropdown.tsx | 8 ++- src/emails/reset-password.tsx | 38 ++++++----- src/emails/verify-email.tsx | 38 ++++++----- src/env.js | 4 ++ src/server/api/routers/subscriptionRouter.ts | 3 +- src/server/auth/endpoints.ts | 4 +- src/server/auth/index.tsx | 69 +++++++++++++------- src/server/email.ts | 42 ++++++++++++ 22 files changed, 274 insertions(+), 95 deletions(-) create mode 100644 src/server/email.ts diff --git a/.env.example b/.env.example index 4ecfe056..edee9ac7 100644 --- a/.env.example +++ b/.env.example @@ -18,9 +18,13 @@ BETTER_AUTH_SECRET= # OAUTH_SCOPES=openid email profile # OAUTH_PKCE=false -# Email -# Not required, but needed for functions like sending password reset emails. +# Email (optional — needed for password reset and email verification) +# FROM_EMAIL_ADDRESS is required for email sending to work. +# If both provider keys are set, Resend takes priority. +FROM_EMAIL_ADDRESS= +RESEND_API_KEY= SENDGRID_API_KEY= +VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS= # Integrations INSTAPAPER_OAUTH_ID= diff --git a/README.md b/README.md index cceba9b9..06115f2b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,10 @@ Serial takes a model of progressive enhancement for features. The app can run wi ### Email support (for password reset, etc) -- Create an account on [Sendgrid](https://sendgrid.com/en-us) -- Set up a mailing address -- Add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI. +Serial supports [Resend](https://resend.com) and [SendGrid](https://sendgrid.com/en-us) as email providers. Only one is used at a time — if both keys are set, Resend takes priority. + +- **Resend**: Create an account, add your `RESEND_API_KEY` to `.env` or your host's environment variables UI. +- **SendGrid**: Create an account, set up a mailing address, add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI. ### Instapaper integration diff --git a/docker-compose.arm.yaml b/docker-compose.arm.yaml index 9745fc35..97043d4d 100644 --- a/docker-compose.arm.yaml +++ b/docker-compose.arm.yaml @@ -21,7 +21,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.build-arm.yaml b/docker-compose.build-arm.yaml index 899ab462..5e2dff77 100644 --- a/docker-compose.build-arm.yaml +++ b/docker-compose.build-arm.yaml @@ -25,7 +25,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.build-cloud.yaml b/docker-compose.build-cloud.yaml index 7b4c37d0..32b00127 100644 --- a/docker-compose.build-cloud.yaml +++ b/docker-compose.build-cloud.yaml @@ -11,7 +11,10 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} ports: diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index e8186987..3a115db4 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -25,7 +25,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.cloud.yaml b/docker-compose.cloud.yaml index 23fcdde6..4a58027f 100644 --- a/docker-compose.cloud.yaml +++ b/docker-compose.cloud.yaml @@ -6,7 +6,10 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} ports: diff --git a/docker-compose.yaml b/docker-compose.yaml index 413d78b0..850af3f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,7 +21,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/package.json b/package.json index bfb725ee..5201abfd 100644 --- a/package.json +++ b/package.json @@ -124,6 +124,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "resend": "^6.11.0", "rss-parser": "^3.13.0", "sonner": "^2.0.7", "superjson": "^2.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2a5a49b..fb7f61af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -249,6 +249,9 @@ importers: remark-rehype: specifier: ^11.1.2 version: 11.1.2 + resend: + specifier: ^6.11.0 + version: 6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) rss-parser: specifier: ^3.13.0 version: 3.13.0 @@ -6505,6 +6508,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -6832,6 +6838,15 @@ packages: reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@6.11.0: + resolution: {integrity: sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -7191,6 +7206,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.90.0: + resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -7547,6 +7565,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -14195,6 +14217,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -14556,6 +14580,13 @@ snapshots: reselect@5.1.1: {} + resend@6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + postal-mime: 2.7.4 + svix: 1.90.0 + optionalDependencies: + '@react-email/render': 2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resolve-pkg-maps@1.0.0: {} resolve@1.22.11: @@ -14990,6 +15021,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.90.0: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -15319,6 +15355,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + v8-compile-cache-lib@3.0.1: {} vary@1.1.2: {} diff --git a/src/app/_app.index.tsx b/src/app/_app.index.tsx index cb9a5dbc..02c24fd0 100644 --- a/src/app/_app.index.tsx +++ b/src/app/_app.index.tsx @@ -14,6 +14,10 @@ import { ViewFilterChips } from "~/components/feed/ViewFilterChips"; import { useUpdateViewFilter } from "~/lib/data/views"; import { useShortcut } from "~/lib/hooks/useShortcut"; import { SHORTCUT_KEYS } from "~/lib/constants/shortcuts"; +import { useFeeds } from "~/lib/data/feeds"; +import { useHasInitialData } from "~/lib/data/store"; +import FeedLoading from "~/components/loading"; +import { FeedEmptyState } from "~/components/feed/view-lists/EmptyStates"; export const Route = createFileRoute("/_app/")({ component: Home, @@ -90,6 +94,21 @@ function Home() { updateViewFilter(views[nextIndex]!.id); }); + const hasInitialData = useHasInitialData(); + const { feeds, hasFetchedFeeds } = useFeeds(); + + if (!hasInitialData) { + return ; + } + + if (hasFetchedFeeds && !feeds.length) { + return ( +
+ +
+ ); + } + return (
diff --git a/src/app/_web.releases.$slug.tsx b/src/app/_web.releases.$slug.tsx index 66691ce8..13f18395 100644 --- a/src/app/_web.releases.$slug.tsx +++ b/src/app/_web.releases.$slug.tsx @@ -14,6 +14,7 @@ export const Route = createFileRoute("/_web/releases/$slug")({ function RouteComponent() { const { release, isAuthed } = Route.useLoaderData(); + const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; return (
@@ -39,12 +40,14 @@ function RouteComponent() { {isAuthed && ( <>

- Thanks for checking out the release log! If you have any questions - or feedback, feel free to send me an email at{" "} - - hey@serial.tube - - . + Thanks for checking out the release log! + {supportEmail && ( + <> + {" "} + If you have any questions or feedback, feel free to send me an + email at {supportEmail}. + + )}

Return to the app → diff --git a/src/app/welcome.tsx b/src/app/welcome.tsx index 9749d25a..86f687d9 100644 --- a/src/app/welcome.tsx +++ b/src/app/welcome.tsx @@ -23,6 +23,7 @@ export const Route = createFileRoute("/welcome")({ function RouteComponent() { const { mostRecentRelease } = Route.useLoaderData(); + const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; return (
@@ -187,17 +188,16 @@ function RouteComponent() {
-
-

- Have a question? Reach us at{" "} - - hey@serial.tube - -

-
+ {supportEmail && ( +
+

+ Have a question? Reach us at{" "} + + {supportEmail} + +

+
+ )} ); } diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 77750dfb..d8d913e2 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -9,6 +9,7 @@ import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; +import { Skeleton } from "~/components/ui/skeleton"; import { useSubscription } from "~/lib/data/subscription"; import { orpc } from "~/lib/orpc"; import { authClient, useSession } from "~/lib/auth-client"; @@ -18,18 +19,21 @@ function formatPrice(cents: number): string { return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; } -function formatRefreshInterval(ms: number | null): string { - if (ms == null) return "Manual refresh only"; - const minutes = ms / (60 * 1000); - if (minutes < 60) return `Background refresh every ${minutes} min`; - const hours = minutes / 60; - return `Background refresh every ${hours} hr`; -} - function getPlanFeatures(plan: PlanConfig): string[] { const features: string[] = []; features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); - features.push(formatRefreshInterval(plan.backgroundRefreshIntervalMs)); + + if (plan.id === "free") { + features.push("Refresh up to once an hour"); + features.push("Manual refresh only"); + } else { + features.push( + plan.id === "pro" + ? "Refreshes once every 5 min" + : "Refreshes once every 15 min", + ); + features.push("Automatically refresh in background"); + } return features; } @@ -132,7 +136,7 @@ export function SubscriptionDialog({ const emailVerified = session?.user?.emailVerified ?? false; - const { data: products } = useQuery({ + const { data: products, isLoading: isLoadingProducts } = useQuery({ ...orpc.subscription.getProducts.queryOptions(), enabled: open, staleTime: 5 * 60 * 1000, @@ -184,10 +188,13 @@ export function SubscriptionDialog({ onOpenChange={onOpenChange} title="Subscription" description="Choose a plan that fits your needs." + className="lg:max-w-4xl" > -
+
{showVerification && !emailVerified && ( - +
+ +
)} {PLAN_IDS.map((id) => { const plan = PLANS[id]; @@ -215,7 +222,9 @@ export function SubscriptionDialog({ )} - {hasPrice ? ( + {isPaid && isLoadingProducts ? ( + + ) : hasPrice ? (

{monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} {monthlyPrice != null && annualPrice != null && " · "} diff --git a/src/components/ui/responsive-dropdown.tsx b/src/components/ui/responsive-dropdown.tsx index f16a6645..7625d4ae 100644 --- a/src/components/ui/responsive-dropdown.tsx +++ b/src/components/ui/responsive-dropdown.tsx @@ -114,6 +114,7 @@ interface ControlledResponsiveDialogProps { children: React.ReactNode; title?: string; description?: string; + className?: string; onBack?: () => void; headerRight?: React.ReactNode; onOpenAutoFocus?: (event: Event) => void; @@ -126,6 +127,7 @@ export function ControlledResponsiveDialog({ description, onBack, headerRight, + className, onOpenAutoFocus, }: ControlledResponsiveDialogProps) { const isDesktop = useMediaQuery("(min-width: 640px)"); @@ -133,7 +135,11 @@ export function ControlledResponsiveDialog({ if (isDesktop) { return (

- + {onBack && ( )} -
- {title} -
+
+ {title} +
{headerRight} From 3c85808b219f9d09ddd88df37fc0c14434e93354 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:05:56 -0400 Subject: [PATCH 03/29] allow bulk toggle on/off --- src/app/_app.feeds.tsx | 59 +++++++++++++++++++++ src/lib/data/feeds/mutations.ts | 16 ++++++ src/server/api/routers/feed-router/index.ts | 39 ++++++++++++++ 3 files changed, 114 insertions(+) diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index 406fcc06..dd14115e 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -20,6 +20,11 @@ import { ChipCombobox } from "~/components/ui/chip-combobox"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; import { Switch } from "~/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; import { useContentCategories } from "~/lib/data/content-categories"; import { useFeedCategories } from "~/lib/data/feed-categories"; import { @@ -29,6 +34,7 @@ import { import { useFeeds } from "~/lib/data/feeds"; import { useBulkDeleteFeedsMutation, + useBulkSetActiveMutation, useSetFeedActiveMutation, } from "~/lib/data/feeds/mutations"; import { useSubscription } from "~/lib/data/subscription"; @@ -94,6 +100,7 @@ function ManageFeedsPage() { useSubscription(); const { mutate: setFeedActive, isPending: isTogglingActive } = useSetFeedActiveMutation(); + const { mutateAsync: bulkSetActive } = useBulkSetActiveMutation(); const [selectedFeedIds, setSelectedFeedIds] = useState>( new Set(), @@ -135,6 +142,7 @@ function ManageFeedsPage() { const [showEditDialog, setShowEditDialog] = useState(false); const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); const [selectedViewIds, setSelectedViewIds] = useState([]); + const [bulkActiveState, setBulkActiveState] = useState(false); const { mutateAsync: bulkDeleteFeeds, isPending: isDeletingFeeds } = useBulkDeleteFeedsMutation(); @@ -294,6 +302,11 @@ function ManageFeedsPage() { const openEditDialog = () => { setSelectedCategoryIds(getSharedCategories()); setSelectedViewIds(getSharedViews()); + // If all selected feeds are active, show active; otherwise show deactivated + const allActive = Array.from(selectedFeedIds).every( + (id) => feeds.find((f) => f.id === id)?.isActive, + ); + setBulkActiveState(allActive); setShowEditDialog(true); }; @@ -349,8 +362,39 @@ function ManageFeedsPage() { const sharedCategories = getSharedCategories(); const sharedViews = getSharedViews(); + // Active state + const feedsToToggle = feedIds.filter((id) => { + const feed = feeds.find((f) => f.id === id); + return feed && feed.isActive !== bulkActiveState; + }); + + if (bulkActiveState && feedsToToggle.length > 0 && maxActiveFeeds >= 0) { + const wouldBeActive = activeFeeds + feedsToToggle.length; + + if (wouldBeActive > maxActiveFeeds) { + const overLimit = wouldBeActive - maxActiveFeeds; + toast.warning( + `${overLimit} feed${overLimit > 1 ? "s would" : " would"} exceed your plan limit. To unlock more active feeds, you can switch to a higher plan.`, + { + action: { + label: "Upgrade", + onClick: () => launchDialog("subscription"), + }, + }, + ); + return; + } + } + const promises: Array> = []; + // Bulk active state toggle + if (feedsToToggle.length > 0) { + promises.push( + bulkSetActive({ feedIds: feedsToToggle, isActive: bulkActiveState }), + ); + } + // Categories const categoriesToAdd = selectedCategoryIds; const categoriesToRemove = sharedCategories.filter( @@ -655,6 +699,21 @@ function ManageFeedsPage() { onOpenChange={setShowEditDialog} title="Edit Feeds" description={`Edit ${selectedCount} feed${selectedCount > 1 ? "s" : ""}.`} + headerRight={ + + +
+ +
+
+ + {bulkActiveState ? "Feeds active" : "Feeds inactive"} + +
+ } >
{ + void fetchFeeds(); + void queryClient.invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }); + }, + }), + ); +} diff --git a/src/server/api/routers/feed-router/index.ts b/src/server/api/routers/feed-router/index.ts index 50b55346..8e6f2e92 100644 --- a/src/server/api/routers/feed-router/index.ts +++ b/src/server/api/routers/feed-router/index.ts @@ -522,6 +522,45 @@ export const setActive = protectedProcedure return feedsSchema.parse(updatedFeed); }); +export const bulkSetActive = protectedProcedure + .input(z.object({ feedIds: z.number().array(), isActive: z.boolean() })) + .handler(async ({ context, input }) => { + if (input.feedIds.length === 0) return; + + if (input.isActive) { + const { remainingSlots } = await getFeedsActivationBudget( + context.db, + context.user.id, + ); + + // Count how many of the requested feeds are currently inactive + const currentFeeds = await context.db.query.feeds.findMany({ + where: and( + inArray(feeds.id, input.feedIds), + eq(feeds.userId, context.user.id), + eq(feeds.isActive, false), + ), + columns: { id: true }, + }); + + if (currentFeeds.length > remainingSlots) { + throw new Error( + "Feed limit reached. Upgrade your plan to activate more feeds.", + ); + } + } + + await context.db + .update(feeds) + .set({ isActive: input.isActive }) + .where( + and( + inArray(feeds.id, input.feedIds), + eq(feeds.userId, context.user.id), + ), + ); + }); + export const discoverFeeds = protectedProcedure .input(z.object({ url: z.string().url() })) .handler(async ({ input }) => { From 07b2c23d6c43f2278966fdea59af361dfae7c7ad Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:10:34 -0400 Subject: [PATCH 04/29] add bulk select with mouse --- src/app/_app.feeds.tsx | 23 ++++++-------- src/app/_app.tags.tsx | 20 ++++++------ src/app/_app.views.tsx | 20 ++++++------ src/lib/hooks/useShiftSelect.ts | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 src/lib/hooks/useShiftSelect.ts diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index dd14115e..e54869b8 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -46,6 +46,7 @@ import { useBulkAssignViewFeedMutation, useBulkRemoveViewFeedMutation, } from "~/lib/data/view-feeds/mutations"; +import { useShiftSelect } from "~/lib/hooks/useShiftSelect"; export const Route = createFileRoute("/_app/feeds")({ component: ManageFeedsPage, @@ -232,22 +233,16 @@ function ManageFeedsPage() { viewNamesMap, ]); + const filteredFeedIds = useMemo( + () => filteredFeeds.map((f) => f.id), + [filteredFeeds], + ); + const handleFeedSelect = useShiftSelect(filteredFeedIds, setSelectedFeedIds); + const selectedCount = selectedFeedIds.size; const allSelected = filteredFeeds.length > 0 && selectedCount === filteredFeeds.length; - const toggleFeedSelection = (feedId: number) => { - setSelectedFeedIds((prev) => { - const next = new Set(prev); - if (next.has(feedId)) { - next.delete(feedId); - } else { - next.add(feedId); - } - return next; - }); - }; - const selectAll = () => { setSelectedFeedIds(new Set(filteredFeeds.map((f) => f.id))); }; @@ -553,12 +548,12 @@ function ManageFeedsPage() { className={`hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors ${ !feed.isActive ? "opacity-50" : "" }`} - onClick={() => toggleFeedSelection(feed.id)} + onClick={(e) => handleFeedSelect(feed.id, e)} > toggleFeedSelection(feed.id)} + onCheckedChange={() => handleFeedSelect(feed.id)} onClick={(e) => e.stopPropagation()} /> filteredTags.map((t) => t.id), + [filteredTags], + ); + const handleTagSelect = useShiftSelect(filteredTagIds, setSelectedTagIds); + const selectedCount = selectedTagIds.size; const allSelected = filteredTags.length > 0 && selectedCount === filteredTags.length; - const toggleTagSelection = (tagId: number) => { - setSelectedTagIds((prev) => { - const next = new Set(prev); - if (next.has(tagId)) next.delete(tagId); - else next.add(tagId); - return next; - }); - }; - const selectAll = () => setSelectedTagIds(new Set(filteredTags.map((t) => t.id))); const deselectAll = () => setSelectedTagIds(new Set()); @@ -299,12 +297,12 @@ function ManageTagsPage() { type="button" key={tag.id} className="hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors" - onClick={() => toggleTagSelection(tag.id)} + onClick={(e) => handleTagSelect(tag.id, e)} > toggleTagSelection(tag.id)} + onCheckedChange={() => handleTagSelect(tag.id)} onClick={(e) => e.stopPropagation()} /> {tag.name} diff --git a/src/app/_app.views.tsx b/src/app/_app.views.tsx index e81150a7..0ef3da6f 100644 --- a/src/app/_app.views.tsx +++ b/src/app/_app.views.tsx @@ -25,6 +25,7 @@ import { useDeleteViewMutation, useEditViewMutation, } from "~/lib/data/views/mutations"; +import { useShiftSelect } from "~/lib/hooks/useShiftSelect"; import { useShortcut } from "~/lib/hooks/useShortcut"; import { VIEW_READ_STATUS } from "~/server/db/constants"; @@ -114,19 +115,16 @@ function ManageViewsPage() { }); }, [customViews, searchQuery, feedNamesMap, categoryNamesMap]); + const filteredViewIds = useMemo( + () => filteredViews.map((v) => v.id), + [filteredViews], + ); + const handleViewSelect = useShiftSelect(filteredViewIds, setSelectedViewIds); + const selectedCount = selectedViewIds.size; const allSelected = filteredViews.length > 0 && selectedCount === filteredViews.length; - const toggleViewSelection = (viewId: number) => { - setSelectedViewIds((prev) => { - const next = new Set(prev); - if (next.has(viewId)) next.delete(viewId); - else next.add(viewId); - return next; - }); - }; - const selectAll = () => setSelectedViewIds(new Set(filteredViews.map((v) => v.id))); const deselectAll = () => setSelectedViewIds(new Set()); @@ -266,12 +264,12 @@ function ManageViewsPage() { type="button" key={view.id} className="hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors" - onClick={() => toggleViewSelection(view.id)} + onClick={(e) => handleViewSelect(view.id, e)} > toggleViewSelection(view.id)} + onCheckedChange={() => handleViewSelect(view.id)} onClick={(e) => e.stopPropagation()} /> {view.name} diff --git a/src/lib/hooks/useShiftSelect.ts b/src/lib/hooks/useShiftSelect.ts new file mode 100644 index 00000000..6a3aa81c --- /dev/null +++ b/src/lib/hooks/useShiftSelect.ts @@ -0,0 +1,54 @@ +import { useCallback, useRef } from "react"; + +/** + * Hook that adds shift-click range selection to a list of items. + * + * @param filteredIds - The ordered array of visible item IDs. + * @param setSelectedIds - State setter for the selected IDs set. + * @returns A `handleSelect` function to use in place of a plain toggle. + * Call it with `(id, event)` from onClick handlers. + */ +export function useShiftSelect( + filteredIds: number[], + setSelectedIds: React.Dispatch>>, +) { + const lastSelectedId = useRef(null); + + const handleSelect = useCallback( + (id: number, event?: { shiftKey?: boolean }) => { + if (event?.shiftKey && lastSelectedId.current !== null) { + const lastIndex = filteredIds.indexOf(lastSelectedId.current); + const currentIndex = filteredIds.indexOf(id); + + if (lastIndex !== -1 && currentIndex !== -1) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + const rangeIds = filteredIds.slice(start, end + 1); + + setSelectedIds((prev) => { + const next = new Set(prev); + rangeIds.forEach((rangeId) => next.add(rangeId)); + return next; + }); + lastSelectedId.current = id; + return; + } + } + + // Normal toggle + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + lastSelectedId.current = id; + }, + [filteredIds, setSelectedIds], + ); + + return handleSelect; +} From 79348f3a6185ab1f886d1e470cc69bcaa38f1dc3 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Thu, 16 Apr 2026 14:17:41 -0400 Subject: [PATCH 05/29] add item limits in dialog chips --- src/components/ui/chip-combobox.tsx | 184 ++++++++++++++++++---------- 1 file changed, 117 insertions(+), 67 deletions(-) diff --git a/src/components/ui/chip-combobox.tsx b/src/components/ui/chip-combobox.tsx index 3dd18a55..067e63a9 100644 --- a/src/components/ui/chip-combobox.tsx +++ b/src/components/ui/chip-combobox.tsx @@ -1,7 +1,13 @@ "use client"; -import { Check, PlusIcon, XIcon } from "lucide-react"; -import { useRef, useState } from "react"; +import { + Check, + ChevronLeftIcon, + ChevronRightIcon, + PlusIcon, + XIcon, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; import { cn } from "~/lib/utils"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; @@ -50,8 +56,10 @@ export function ChipCombobox({ badgeVariant = "outline", emptyMessage = "No options found.", }: ChipComboboxProps) { + const PAGE_SIZE = 25; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); const inputRef = useRef(null); const selectedSet = new Set(selectedIds); @@ -59,6 +67,19 @@ export function ChipCombobox({ .filter((o) => selectedSet.has(o.id)) .sort((a, b) => a.label.localeCompare(b.label)); + const totalPages = Math.ceil(selectedOptions.length / PAGE_SIZE); + const pagedOptions = selectedOptions.slice( + page * PAGE_SIZE, + (page + 1) * PAGE_SIZE, + ); + + // Reset page when selection changes and current page is out of bounds + useEffect(() => { + if (page >= totalPages && totalPages > 0) { + setPage(totalPages - 1); + } + }, [page, totalPages]); + const trimmedSearch = search.trim(); const lowerSearch = trimmedSearch.toLowerCase(); const filteredOptions = ( @@ -84,82 +105,111 @@ export function ChipCombobox({ return (
-
- - - +
+
+ + + + + + + + + + {filteredOptions.length === 0 && !canCreate && ( + {emptyMessage} + )} + + {filteredOptions.map((option) => { + const isSelected = selectedSet.has(option.id); + return ( + { + if (isSelected) { + onRemove(option.id); + } else { + onAdd(option.id); + } + setSearch(""); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }} + > + + {option.label} + + ); + })} + {canCreate && ( + + + + {createLabel ?? "Create"} "{trimmedSearch}" + + + )} + + + + + +
+ {totalPages > 1 && ( +
+ + {page + 1}/{totalPages} + - - - = totalPages - 1} + onClick={() => setPage((p) => p + 1)} > - - - {filteredOptions.length === 0 && !canCreate && ( - {emptyMessage} - )} - - {filteredOptions.map((option) => { - const isSelected = selectedSet.has(option.id); - return ( - { - if (isSelected) { - onRemove(option.id); - } else { - onAdd(option.id); - } - setSearch(""); - requestAnimationFrame(() => { - inputRef.current?.focus(); - }); - }} - > - - {option.label} - - ); - })} - {canCreate && ( - - - - {createLabel ?? "Create"} "{trimmedSearch}" - - - )} - - - - - + + +
+ )}
{selectedOptions.length > 0 ? (
- {selectedOptions.map((option) => ( + {pagedOptions.map((option) => ( Date: Thu, 16 Apr 2026 16:26:32 -0400 Subject: [PATCH 06/29] better chip pagination, don't overflow responsive dialog --- src/app/_app.feeds.tsx | 54 +++---- src/components/AddContentCategoryDialog.tsx | 16 +- src/components/AddFeedDialog.tsx | 114 +++++++------- src/components/AddViewDialog.tsx | 42 +++--- src/components/feed/SubscriptionDialog.tsx | 2 +- src/components/ui/chip-combobox.tsx | 158 +++++++++++++++++--- src/components/ui/responsive-dropdown.tsx | 22 ++- 7 files changed, 272 insertions(+), 136 deletions(-) diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index e54869b8..c62db6ba 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -709,6 +709,34 @@ function ManageFeedsPage() { } + footer={ +
+ + +
+ } >
-
- - -
diff --git a/src/components/AddContentCategoryDialog.tsx b/src/components/AddContentCategoryDialog.tsx index 7f32fa54..271e7793 100644 --- a/src/components/AddContentCategoryDialog.tsx +++ b/src/components/AddContentCategoryDialog.tsx @@ -285,13 +285,7 @@ export function EditContentCategoryDialog({ open={selectedContentCategoryId !== null} onOpenChange={onClose} title="Edit Tag" - > -
- - + footer={
+ } + > +
+ +
); diff --git a/src/components/AddFeedDialog.tsx b/src/components/AddFeedDialog.tsx index 87abb84d..33dba884 100644 --- a/src/components/AddFeedDialog.tsx +++ b/src/components/AddFeedDialog.tsx @@ -340,6 +340,64 @@ export function EditFeedDialog({ } + footer={ +
+ + +
+ } >
@@ -422,62 +480,6 @@ export function EditFeedDialog({ openLocation={selectedOpenLocation} setOpenLocation={setSelectedOpenLocation} /> -
- - -
); diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx index e85b651e..050c55e9 100644 --- a/src/components/AddViewDialog.tsx +++ b/src/components/AddViewDialog.tsx @@ -517,26 +517,7 @@ export function EditViewDialog({ open={selectedViewId !== null} onOpenChange={onClose} title="Edit View" - > -
- - - - - - + footer={
+ } + > +
+ + + + + +
); diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index eca26f3b..55af3dc6 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -292,7 +292,7 @@ export function SubscriptionDialog({ ); })}
-

+

Price too high?{" "} lastTop + 1) { + // New row (with 1px tolerance for sub-pixel rounding) + rowCount++; + if (rowCount > MAX_ROWS) break; + lastTop = top; + } + count++; + clipBottom = Math.round(rect.bottom); + } + + return { + count, + clipHeight: rowCount > MAX_ROWS ? clipBottom - Math.round(containerTop) : 0, + }; +} + export function ChipCombobox({ label, placeholder, @@ -56,30 +102,99 @@ export function ChipCombobox({ badgeVariant = "outline", emptyMessage = "No options found.", }: ChipComboboxProps) { - const PAGE_SIZE = 25; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const [page, setPage] = useState(0); const inputRef = useRef(null); + // Pagination state + const [offset, setOffset] = useState(0); + const [visibleCount, setVisibleCount] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [firstPageCount, setFirstPageCount] = useState(0); + const maxClipHeightRef = useRef(0); + const prevOffsets = useRef([]); + const badgeContainerRef = useRef(null); + const selectedSet = new Set(selectedIds); const selectedOptions = options .filter((o) => selectedSet.has(o.id)) .sort((a, b) => a.label.localeCompare(b.label)); - const totalPages = Math.ceil(selectedOptions.length / PAGE_SIZE); - const pagedOptions = selectedOptions.slice( - page * PAGE_SIZE, - (page + 1) * PAGE_SIZE, - ); + // Badges to render — enough to fill 5 rows and detect overflow + const renderOptions = selectedOptions.slice(offset, offset + RENDER_CHUNK); + const totalCount = selectedOptions.length; + const hasMore = totalCount > 0 && offset + visibleCount < totalCount; + const hasPrev = offset > 0; + const showPagination = hasMore || hasPrev; + // Estimate total pages from first page's count (best available approximation) + const estimatedTotalPages = + firstPageCount > 0 ? Math.ceil(totalCount / firstPageCount) : 1; - // Reset page when selection changes and current page is out of bounds + // Reset pagination when selection count changes useEffect(() => { - if (page >= totalPages && totalPages > 0) { - setPage(totalPages - 1); + setOffset(0); + setVisibleCount(0); + setCurrentPage(1); + setFirstPageCount(0); + maxClipHeightRef.current = 0; + prevOffsets.current = []; + }, [totalCount]); + + // Measure how many badges fit in MAX_ROWS rows and clip the container. + // useLayoutEffect runs synchronously before paint, so there's no flicker. + useLayoutEffect(() => { + const container = badgeContainerRef.current; + if (!container) return; + + // Remove clip to measure natural layout + container.style.maxHeight = "none"; + container.style.minHeight = ""; + + const { count, clipHeight } = measureVisibleCount(container); + + // Track the tallest page so the container doesn't collapse on the last page + const effectiveHeight = + clipHeight > 0 + ? clipHeight + : Math.round(container.getBoundingClientRect().height); + if (effectiveHeight > maxClipHeightRef.current) { + maxClipHeightRef.current = effectiveHeight; + } + + // Store first page's count for total page estimation + if (offset === 0 && count > 0) { + setFirstPageCount(count); + } + + if (clipHeight > 0) { + container.style.maxHeight = `${clipHeight}px`; + } else { + container.style.maxHeight = ""; + } + + // Prevent height collapse on pages with fewer items + if (maxClipHeightRef.current > 0) { + container.style.minHeight = `${maxClipHeightRef.current}px`; + } + + setVisibleCount(count); + }); + + const goForward = useCallback(() => { + prevOffsets.current.push(offset); + setOffset(offset + visibleCount); + setCurrentPage((p) => p + 1); + }, [offset, visibleCount]); + + const goBack = useCallback(() => { + const prev = prevOffsets.current.pop(); + if (prev !== undefined) { + setOffset(prev); + setCurrentPage((p) => p - 1); } - }, [page, totalPages]); + }, []); + // Search / filter state const trimmedSearch = search.trim(); const lowerSearch = trimmedSearch.toLowerCase(); const filteredOptions = ( @@ -179,18 +294,18 @@ export function ChipCombobox({

- {totalPages > 1 && ( + {showPagination && (
- {page + 1}/{totalPages} + {currentPage}/{estimatedTotalPages} @@ -199,8 +314,8 @@ export function ChipCombobox({ size="icon" className="size-6" type="button" - disabled={page >= totalPages - 1} - onClick={() => setPage((p) => p + 1)} + disabled={!hasMore} + onClick={goForward} > @@ -208,8 +323,11 @@ export function ChipCombobox({ )}
{selectedOptions.length > 0 ? ( -
- {pagedOptions.map((option) => ( +
+ {renderOptions.map((option) => ( void; headerRight?: React.ReactNode; + footer?: React.ReactNode; onOpenAutoFocus?: (event: Event) => void; } export function ControlledResponsiveDialog({ @@ -130,6 +132,7 @@ export function ControlledResponsiveDialog({ headerRight, className, headerClassName, + footer, onOpenAutoFocus, }: ControlledResponsiveDialogProps) { const isDesktop = useMediaQuery("(min-width: 640px)"); @@ -139,10 +142,10 @@ export function ControlledResponsiveDialog({ - + {onBack && ( ); @@ -172,8 +176,8 @@ export function ControlledResponsiveDialog({ return ( - - + + {onBack && ( +
+ + ); +} + function RootLayout() { const { mostRecentRelease } = Route.useLoaderData(); useAltKeyHeld(); + const { + showDialog: showCheckoutSuccess, + setShowDialog: setShowCheckoutSuccess, + } = useCheckoutSuccess(); return ( // @@ -51,6 +152,10 @@ function RootLayout() {
+ diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 55af3dc6..a5712138 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -19,7 +19,7 @@ import { useSubscription } from "~/lib/data/subscription"; import { orpc } from "~/lib/orpc"; import { authClient, useSession } from "~/lib/auth-client"; -const PLAN_ICONS = { +export const PLAN_ICONS = { free: SproutIcon, standard: TreeDeciduousIcon, pro: TreesIcon, @@ -30,7 +30,7 @@ function formatPrice(cents: number): string { return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; } -function getPlanFeatures(plan: PlanConfig): string[] { +export function getPlanFeatures(plan: PlanConfig): string[] { const features: string[] = []; features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); @@ -246,11 +246,7 @@ export function SubscriptionDialog({ {monthlyPrice != null && annualPrice != null && " · "} {annualPrice != null && `${formatPrice(annualPrice)}/yr`}

- ) : ( -

- {isPaid ? "" : "Free"} -

- )} + ) : null}
    {features.map((feature) => ( diff --git a/src/components/ui/responsive-dropdown.tsx b/src/components/ui/responsive-dropdown.tsx index 9db2e099..e8e35570 100644 --- a/src/components/ui/responsive-dropdown.tsx +++ b/src/components/ui/responsive-dropdown.tsx @@ -167,7 +167,9 @@ export function ControlledResponsiveDialog({
{description} -
{children}
+
+ {children} +
{footer &&
{footer}
}
diff --git a/src/lib/data/subscription.ts b/src/lib/data/subscription.ts index 94b0e7f5..915dc4fe 100644 --- a/src/lib/data/subscription.ts +++ b/src/lib/data/subscription.ts @@ -4,7 +4,7 @@ import { orpc } from "~/lib/orpc"; export function useSubscription() { const { data, isLoading } = useQuery({ ...orpc.subscription.getStatus.queryOptions(), - staleTime: 60_000, + staleTime: 15_000, }); return { diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index 1bef248e..23312927 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -113,6 +113,9 @@ function buildPolarPlugin() { secret: process.env.POLAR_WEBHOOK_SECRET ?? "", onSubscriptionActive: async (payload) => { const externalId = payload.data.customer?.externalId; + console.log( + `[polar webhook] onSubscriptionActive: user=${externalId ?? "unknown"} subscription=${payload.data.id} product=${payload.data.productId}`, + ); if (!externalId) return; invalidatePlanCache(externalId); @@ -124,6 +127,10 @@ function buildPolarPlugin() { return; } + console.log( + `[polar webhook] Resolved plan="${planId}" for user=${externalId}`, + ); + const config = PLANS[planId]; if (config.backgroundRefreshIntervalMs) { // Stagger nextFetchAt across the refresh interval so feeds @@ -161,9 +168,15 @@ function buildPolarPlugin() { } }, onSubscriptionCanceled: async (payload) => { + console.log( + `[polar webhook] onSubscriptionCanceled: user=${payload.data.customer?.externalId ?? "unknown"} subscription=${payload.data.id}`, + ); await handleSubscriptionEnd(payload); }, onSubscriptionRevoked: async (payload) => { + console.log( + `[polar webhook] onSubscriptionRevoked: user=${payload.data.customer?.externalId ?? "unknown"} subscription=${payload.data.id}`, + ); await handleSubscriptionEnd(payload); }, }), diff --git a/src/server/subscriptions/helpers.ts b/src/server/subscriptions/helpers.ts index 656b7f73..8e9b28d5 100644 --- a/src/server/subscriptions/helpers.ts +++ b/src/server/subscriptions/helpers.ts @@ -7,7 +7,7 @@ import { feeds } from "~/server/db/schema"; type DB = typeof Database; -const PLAN_CACHE_TTL_MS = 60_000 * 4; // 4 minutes +const PLAN_CACHE_TTL_MS = 60_000; // 1 minute const planCache = new Map(); export async function getActiveFeedCount(db: DB, userId: string) { From a2ce78bc0247cee9c798ddd8bfcfbb146655867e Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:05:54 -0400 Subject: [PATCH 08/29] ui tweaks, improvements --- src/app/_app.feeds.tsx | 18 +- src/app/_app.tsx | 20 +-- src/components/feed/FeedManagementTabs.tsx | 20 ++- src/components/feed/SubscriptionDialog.tsx | 164 ++++++++++++++++++- src/components/ui/tabs.tsx | 5 +- src/lib/data/plan-success.ts | 13 ++ src/server/api/routers/subscriptionRouter.ts | 111 ++++++++++++- 7 files changed, 324 insertions(+), 27 deletions(-) create mode 100644 src/lib/data/plan-success.ts diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index c62db6ba..a25d33ca 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -13,6 +13,7 @@ import { FeedManagementTabs } from "~/components/feed/FeedManagementTabs"; import { useFeedManagementShortcuts } from "~/components/feed/useManagementShortcuts"; import { FeedEmptyState } from "~/components/feed/view-lists/EmptyStates"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Progress } from "~/components/ui/progress"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -453,11 +454,6 @@ function ManageFeedsPage() {
- {billingEnabled && maxActiveFeeds > 0 && ( -

- {activeFeeds} / {maxActiveFeeds} feeds active -

- )}
+ {billingEnabled && maxActiveFeeds > 0 && ( +
+
+

+ {activeFeeds} / {maxActiveFeeds} feeds active +

+
+ +
+ )} {billingEnabled && maxActiveFeeds > 0 && activeFeeds >= maxActiveFeeds && ( diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 1151fd84..97e6337a 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -15,6 +15,7 @@ import { ImpersonationBanner } from "~/components/ImpersonationBanner"; import { ReleaseNotifier } from "~/components/releases/ReleaseNotifier"; import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; import { InitialClientQueries } from "~/lib/data/InitialClientQueries"; +import { usePlanSuccessStore } from "~/lib/data/plan-success"; import { useSubscription } from "~/lib/data/subscription"; import { useAltKeyHeld } from "~/lib/hooks/useAltKeyHeld"; import { authMiddleware } from "~/server/auth"; @@ -39,9 +40,9 @@ export const Route = createFileRoute("/_app")({ function useCheckoutSuccess() { const queryClient = useQueryClient(); - const [showDialog, setShowDialog] = useState(false); const [awaitingUpgrade, setAwaitingUpgrade] = useState(false); const { planId } = useSubscription(); + const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); // Detect checkout_success query param on mount useEffect(() => { @@ -65,7 +66,7 @@ function useCheckoutSuccess() { if (planId !== "free") { // Webhook has been processed — plan is upgraded setAwaitingUpgrade(false); - setShowDialog(true); + openPlanSuccess(); return; } @@ -77,9 +78,7 @@ function useCheckoutSuccess() { }, 2000); return () => clearInterval(interval); - }, [awaitingUpgrade, planId, queryClient]); - - return { showDialog, setShowDialog }; + }, [awaitingUpgrade, planId, queryClient, openPlanSuccess]); } function CheckoutSuccessDialog({ @@ -126,10 +125,9 @@ function CheckoutSuccessDialog({ function RootLayout() { const { mostRecentRelease } = Route.useLoaderData(); useAltKeyHeld(); - const { - showDialog: showCheckoutSuccess, - setShowDialog: setShowCheckoutSuccess, - } = useCheckoutSuccess(); + useCheckoutSuccess(); + const showPlanSuccess = usePlanSuccessStore((s) => s.showDialog); + const closePlanSuccess = usePlanSuccessStore((s) => s.closeDialog); return ( // @@ -153,8 +151,8 @@ function RootLayout() {
diff --git a/src/components/feed/FeedManagementTabs.tsx b/src/components/feed/FeedManagementTabs.tsx index c32a7c37..eb71512f 100644 --- a/src/components/feed/FeedManagementTabs.tsx +++ b/src/components/feed/FeedManagementTabs.tsx @@ -30,23 +30,35 @@ export function FeedManagementTabs({ value }: { value: FeedManagementTab }) { return ( - + Feeds - + Views - + Tags - + diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index a5712138..9de6d26f 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon, SproutIcon, @@ -15,6 +15,7 @@ import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; import { Skeleton } from "~/components/ui/skeleton"; +import { usePlanSuccessStore } from "~/lib/data/plan-success"; import { useSubscription } from "~/lib/data/subscription"; import { orpc } from "~/lib/orpc"; import { authClient, useSession } from "~/lib/auth-client"; @@ -131,6 +132,92 @@ function EmailVerificationBanner({ onVerified }: { onVerified: () => void }) { ); } +type SwitchPreview = { + currentPlanId: string; + currentPlanName: string; + currentAmount: number; + newPlanId: string; + newPlanName: string; + newAmount: number; + proratedAmount: number; + currency: string; + billingInterval: "month" | "year"; + subscriptionId: string; + newProductId: string; +}; + +function PlanSwitchConfirmation({ + preview, + onBack, + onConfirm, + isPending, +}: { + preview: SwitchPreview; + onBack: () => void; + onConfirm: () => void; + isPending: boolean; +}) { + const newPlan = PLANS[preview.newPlanId as keyof typeof PLANS]; + const features = getPlanFeatures(newPlan); + const Icon = PLAN_ICONS[preview.newPlanId as keyof typeof PLAN_ICONS]; + const intervalLabel = preview.billingInterval === "month" ? "mo" : "yr"; + + return ( + onBack()} + title="Switch Plan" + description={`Switch from ${preview.currentPlanName} to ${preview.newPlanName}`} + onBack={onBack} + footer={ + + } + > +
+
+ +
+

{preview.newPlanName} Plan

+

+ {formatPrice(preview.newAmount)}/{intervalLabel} +

+
+
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {preview.proratedAmount > 0 && ( +
+

+ + Due today (prorated): + {" "} + + {formatPrice(preview.proratedAmount)} + +

+

+ You'll be credited for the unused time on your current plan. +

+
+ )} +
+
+ ); +} + export function SubscriptionDialog({ open, onOpenChange, @@ -140,10 +227,14 @@ export function SubscriptionDialog({ }) { const { planId } = useSubscription(); const { data: session, refetch: refetchSession } = useSession(); + const queryClient = useQueryClient(); const [showVerification, setShowVerification] = useState(false); const [pendingPlanId, setPendingPlanId] = useState<"standard" | "pro" | null>( null, ); + const [switchPreview, setSwitchPreview] = useState( + null, + ); const emailVerified = session?.user?.emailVerified ?? false; @@ -168,6 +259,42 @@ export function SubscriptionDialog({ }), ); + const previewMutation = useMutation( + orpc.subscription.previewPlanSwitch.mutationOptions({ + onSuccess: (result) => { + if (result) { + setSwitchPreview(result); + } else { + toast.error("Unable to preview plan switch"); + } + }, + }), + ); + + const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); + + const switchMutation = useMutation( + orpc.subscription.switchPlan.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + setSwitchPreview(null); + onOpenChange(false); + // Invalidate and then show success dialog + void queryClient + .invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }) + .then(() => { + openPlanSuccess(); + }); + } + }, + onError: () => { + toast.error("Failed to switch plan. Please try again."); + }, + }), + ); + const portalMutation = useMutation( orpc.subscription.createPortalSession.mutationOptions({ onSuccess: (result) => { @@ -182,17 +309,42 @@ export function SubscriptionDialog({ function handleSubscribeClick(id: "standard" | "pro") { setPendingPlanId(id); - checkoutMutation.mutate({ planId: id }); + if (isSubscribed) { + // Already subscribed — show switch confirmation + previewMutation.mutate({ planId: id }); + } else { + checkoutMutation.mutate({ planId: id }); + } } async function handleVerified() { await refetchSession(); setShowVerification(false); if (pendingPlanId) { - checkoutMutation.mutate({ planId: pendingPlanId }); + if (isSubscribed) { + previewMutation.mutate({ planId: pendingPlanId }); + } else { + checkoutMutation.mutate({ planId: pendingPlanId }); + } } } + if (switchPreview) { + return ( + setSwitchPreview(null)} + onConfirm={() => + switchMutation.mutate({ + subscriptionId: switchPreview.subscriptionId, + newProductId: switchPreview.newProductId, + }) + } + isPending={switchMutation.isPending} + /> + ); + } + return ( handleSubscribeClick(id)} > - Subscribe + {isSubscribed ? "Switch" : "Subscribe"}
)} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index cbfb5684..32e7a724 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -26,12 +26,13 @@ function Tabs({ } const tabsListVariants = cva( - "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none", + "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground data-[variant=line]:rounded-none data-[variant=pill]:rounded-none", { variants: { variant: { default: "h-9 bg-muted/30", line: "h-9 gap-1 bg-transparent", + pill: "h-9 gap-1 rounded-lg bg-transparent p-0", }, }, defaultVariants: { @@ -71,6 +72,8 @@ function TabsTrigger({ "group-data-[variant=line]/tabs-list:data-[state=active]:text-foreground group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent", // underline pseudo-element for line variant "after:bg-foreground after:absolute after:inset-x-0 after:bottom-[-5px] after:h-0.5 after:opacity-0 after:transition-opacity group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100", + // pill variant: muted bg on active, lighter muted on hover, smooth transition + "group-data-[variant=pill]/tabs-list:hover:bg-muted/50 group-data-[variant=pill]/tabs-list:data-[state=active]:bg-muted group-data-[variant=pill]/tabs-list:data-[state=active]:text-foreground group-data-[variant=pill]/tabs-list:transition-colors group-data-[variant=pill]/tabs-list:after:hidden group-data-[variant=pill]/tabs-list:data-[state=active]:shadow-none dark:group-data-[variant=pill]/tabs-list:data-[state=active]:border-transparent", className, )} {...props} diff --git a/src/lib/data/plan-success.ts b/src/lib/data/plan-success.ts new file mode 100644 index 00000000..98373d1a --- /dev/null +++ b/src/lib/data/plan-success.ts @@ -0,0 +1,13 @@ +import { create } from "zustand"; + +type PlanSuccessStore = { + showDialog: boolean; + openDialog: () => void; + closeDialog: () => void; +}; + +export const usePlanSuccessStore = create((set) => ({ + showDialog: false, + openDialog: () => set({ showDialog: true }), + closeDialog: () => set({ showDialog: false }), +})); diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 14cb20d5..0292e813 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -4,7 +4,10 @@ import type { PlanId } from "~/server/subscriptions/plans"; import { protectedProcedure } from "~/server/orpc/base"; import { getUserPlanLimits } from "~/server/subscriptions/helpers"; import { IS_BILLING_ENABLED, polarClient } from "~/server/subscriptions/polar"; -import { PLANS } from "~/server/subscriptions/plans"; +import { + determinePlanFromProductId, + PLANS, +} from "~/server/subscriptions/plans"; import { user } from "~/server/db/schema"; import { IS_EMAIL_ENABLED } from "~/server/email"; @@ -170,6 +173,112 @@ export const createCheckout = protectedProcedure return { url: checkout.url, error: null }; }); +export const previewPlanSwitch = protectedProcedure + .input(z.object({ planId: z.enum(["standard", "pro"]) })) + .handler(async ({ context, input }) => { + if (!IS_BILLING_ENABLED || !polarClient) { + return null; + } + + // Find current active subscription + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [context.user.id], + active: true, + }); + + const currentSub = subscriptions.result?.items?.[0]; + if (!currentSub) return null; + + const currentPlanId = determinePlanFromProductId(currentSub.productId); + if (!currentPlanId || currentPlanId === input.planId) return null; + + const newPlan = PLANS[input.planId]; + const isMonthly = currentSub.recurringInterval === "month"; + const newProductId = isMonthly + ? newPlan.polarMonthlyProductId + : newPlan.polarAnnualProductId; + + if (!newProductId) return null; + + // Get the new product price + let newAmount: number | null = null; + try { + const product = await polarClient.products.get({ id: newProductId }); + const price = product.prices?.[0]; + if (price && "amountType" in price && price.amountType === "fixed") { + newAmount = (price as { priceAmount: number }).priceAmount; + } + } catch { + return null; + } + + // Calculate proration + const now = Date.now(); + const periodStart = new Date(currentSub.currentPeriodStart).getTime(); + const periodEnd = new Date(currentSub.currentPeriodEnd).getTime(); + const totalPeriod = periodEnd - periodStart; + const elapsed = now - periodStart; + const remaining = Math.max(0, 1 - elapsed / totalPeriod); + + const currentCredit = Math.round(currentSub.amount * remaining); + const newCharge = Math.round((newAmount ?? 0) * remaining); + const proratedAmount = Math.max(0, newCharge - currentCredit); + + return { + currentPlanId, + currentPlanName: PLANS[currentPlanId].name, + currentAmount: currentSub.amount, + newPlanId: input.planId, + newPlanName: newPlan.name, + newAmount: newAmount ?? 0, + proratedAmount, + currency: currentSub.currency, + billingInterval: currentSub.recurringInterval as "month" | "year", + subscriptionId: currentSub.id, + newProductId, + }; + }); + +export const switchPlan = protectedProcedure + .input( + z.object({ + subscriptionId: z.string(), + newProductId: z.string(), + }), + ) + .handler(async ({ context, input }) => { + if (!IS_BILLING_ENABLED || !polarClient) { + return { success: false }; + } + + // Verify the subscription belongs to this user + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [context.user.id], + active: true, + }); + + const sub = subscriptions.result?.items?.find( + (s) => s.id === input.subscriptionId, + ); + if (!sub) { + throw new Error("Subscription not found"); + } + + await polarClient.subscriptions.update({ + id: input.subscriptionId, + subscriptionUpdate: { + productId: input.newProductId, + prorationBehavior: "prorate", + }, + }); + + console.log( + `[polar] Plan switched for user=${context.user.id} subscription=${input.subscriptionId} newProduct=${input.newProductId}`, + ); + + return { success: true }; + }); + export const createPortalSession = protectedProcedure.handler( async ({ context }) => { if (!IS_BILLING_ENABLED || !polarClient) { From 9d0decc678e41c1e8e06cf3a0bb0facfed71b6d8 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Thu, 16 Apr 2026 20:56:05 -0400 Subject: [PATCH 09/29] surface errors for background refresh, better loading state post upgrade --- server/tasks/feeds/background-refresh.ts | 16 +++++++++ src/app/_app.feeds.tsx | 24 +++++++------ src/app/_app.tsx | 37 ++++++++++++++------ src/components/feed/SubscriptionDialog.tsx | 10 ++++-- src/server/api/routers/subscriptionRouter.ts | 27 +++++++++++--- src/server/rss/fetchFeeds.ts | 13 +++++-- 6 files changed, 98 insertions(+), 29 deletions(-) diff --git a/server/tasks/feeds/background-refresh.ts b/server/tasks/feeds/background-refresh.ts index 248ab7e9..4f2f85fc 100644 --- a/server/tasks/feeds/background-refresh.ts +++ b/server/tasks/feeds/background-refresh.ts @@ -73,6 +73,14 @@ export default defineTask({ let emptyCount = 0; let errorCount = 0; + // Map feed ID → name for error logging + const feedNameMap = new Map(); + for (const userFeeds of feedsByUser.values()) { + for (const feed of userFeeds) { + feedNameMap.set(feed.id, feed.name); + } + } + for (const [userId, userFeeds] of feedsByUser) { try { // Fetch and insert feed data @@ -86,6 +94,14 @@ export default defineTask({ emptyCount++; } else if (result.status === "error") { errorCount++; + const feedName = feedNameMap.get(result.id) ?? "unknown"; + const errMsg = + result.error instanceof Error + ? result.error.message + : String(result.error); + console.error( + `[background-refresh] Error refreshing feed "${feedName}" (id=${result.id}, user=${userId}): ${errMsg}`, + ); } } } catch (e) { diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index a25d33ca..a32e547c 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -464,18 +464,20 @@ function ManageFeedsPage() { - {billingEnabled && maxActiveFeeds > 0 && ( -
-
-

- {activeFeeds} / {maxActiveFeeds} feeds active -

+ {billingEnabled && + maxActiveFeeds > 0 && + activeFeeds < maxActiveFeeds && ( +
+
+

+ {activeFeeds} / {maxActiveFeeds} feeds active +

+
+
- -
- )} + )} {billingEnabled && maxActiveFeeds > 0 && activeFeeds >= maxActiveFeeds && ( diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 97e6337a..822c4573 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -20,7 +20,7 @@ import { useSubscription } from "~/lib/data/subscription"; import { useAltKeyHeld } from "~/lib/hooks/useAltKeyHeld"; import { authMiddleware } from "~/server/auth"; import { getMostRecentRelease } from "~/lib/markdown/loaders"; -import { orpc } from "~/lib/orpc"; +import { orpc, orpcRouterClient } from "~/lib/orpc"; import { PLANS } from "~/server/subscriptions/plans"; import { getPlanFeatures, @@ -59,26 +59,39 @@ function useCheckoutSuccess() { setAwaitingUpgrade(true); }, []); - // Poll subscription status until the plan upgrades + // Poll subscription status until the plan upgrades, using refreshStatus + // to bypass the server-side plan cache on each attempt useEffect(() => { if (!awaitingUpgrade) return; if (planId !== "free") { - // Webhook has been processed — plan is upgraded + // Plan is upgraded setAwaitingUpgrade(false); openPlanSuccess(); return; } - // Plan still shows free — keep polling - const interval = setInterval(() => { - void queryClient.invalidateQueries({ - queryKey: orpc.subscription.getStatus.queryOptions().queryKey, - }); - }, 2000); + // Force-refresh from Polar (cache-busting) every 2s + const poll = async () => { + try { + const result = await orpcRouterClient.subscription.refreshStatus(); + // Update the getStatus query data with the fresh result + queryClient.setQueryData( + orpc.subscription.getStatus.queryOptions().queryKey, + result, + ); + } catch { + // Ignore errors, will retry on next interval + } + }; + + void poll(); // Immediately on first run + const interval = setInterval(() => void poll(), 2000); return () => clearInterval(interval); }, [awaitingUpgrade, planId, queryClient, openPlanSuccess]); + + return { awaitingUpgrade }; } function CheckoutSuccessDialog({ @@ -125,10 +138,14 @@ function CheckoutSuccessDialog({ function RootLayout() { const { mostRecentRelease } = Route.useLoaderData(); useAltKeyHeld(); - useCheckoutSuccess(); + const { awaitingUpgrade } = useCheckoutSuccess(); const showPlanSuccess = usePlanSuccessStore((s) => s.showDialog); const closePlanSuccess = usePlanSuccessStore((s) => s.closeDialog); + if (awaitingUpgrade) { + return ; + } + return ( // }> diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 9de6d26f..a759fe51 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -313,7 +313,10 @@ export function SubscriptionDialog({ // Already subscribed — show switch confirmation previewMutation.mutate({ planId: id }); } else { - checkoutMutation.mutate({ planId: id }); + checkoutMutation.mutate({ + planId: id, + returnPath: window.location.pathname, + }); } } @@ -324,7 +327,10 @@ export function SubscriptionDialog({ if (isSubscribed) { previewMutation.mutate({ planId: pendingPlanId }); } else { - checkoutMutation.mutate({ planId: pendingPlanId }); + checkoutMutation.mutate({ + planId: pendingPlanId, + returnPath: window.location.pathname, + }); } } } diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 0292e813..fbfaabf0 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -2,7 +2,10 @@ import { z } from "zod"; import { eq } from "drizzle-orm"; import type { PlanId } from "~/server/subscriptions/plans"; import { protectedProcedure } from "~/server/orpc/base"; -import { getUserPlanLimits } from "~/server/subscriptions/helpers"; +import { + getUserPlanLimits, + invalidatePlanCache, +} from "~/server/subscriptions/helpers"; import { IS_BILLING_ENABLED, polarClient } from "~/server/subscriptions/polar"; import { determinePlanFromProductId, @@ -50,6 +53,12 @@ export const getStatus = protectedProcedure.handler(async ({ context }) => { return getUserPlanLimits(context.db, context.user.id); }); +/** Force-refresh the plan from Polar, bypassing the server cache. */ +export const refreshStatus = protectedProcedure.handler(async ({ context }) => { + invalidatePlanCache(context.user.id); + return getUserPlanLimits(context.db, context.user.id); +}); + export const getProducts = protectedProcedure.handler(async () => { if (!IS_BILLING_ENABLED || !polarClient) { return []; @@ -133,7 +142,12 @@ export const getProducts = protectedProcedure.handler(async () => { }); export const createCheckout = protectedProcedure - .input(z.object({ planId: z.enum(["standard", "pro"]) })) + .input( + z.object({ + planId: z.enum(["standard", "pro"]), + returnPath: z.string().optional(), + }), + ) .handler(async ({ context, input }) => { if (!IS_BILLING_ENABLED || !polarClient) { return { url: null, error: null }; @@ -162,12 +176,17 @@ export const createCheckout = protectedProcedure } const origin = getValidatedOrigin(context.headers); + // Ensure returnPath starts with / and doesn't contain query params that could break the URL + const safePath = input.returnPath?.startsWith("/") + ? input.returnPath.split("?")[0]! + : "/"; + const separator = safePath.includes("?") ? "&" : "?"; const checkout = await polarClient.checkouts.create({ externalCustomerId: context.user.id, customerEmail: context.user.email, products: productIds, - successUrl: `${origin}/?checkout_success=true`, - returnUrl: `${origin}/`, + successUrl: `${origin}${safePath}${separator}checkout_success=true`, + returnUrl: `${origin}${safePath}`, }); return { url: checkout.url, error: null }; diff --git a/src/server/rss/fetchFeeds.ts b/src/server/rss/fetchFeeds.ts index 1bff8055..ab2c5b9f 100644 --- a/src/server/rss/fetchFeeds.ts +++ b/src/server/rss/fetchFeeds.ts @@ -69,8 +69,13 @@ type FeedResult = id: number; } | { - status: "empty" | "error" | "skipped"; + status: "empty" | "skipped"; id: number; + } + | { + status: "error"; + id: number; + error: unknown; }; export async function* fetchAndInsertFeedData( @@ -114,6 +119,9 @@ export async function* fetchAndInsertFeedData( return { status: "error", id: feed.id, + error: new Error( + `No feed data returned for platform: ${feed.platform}`, + ), }; } @@ -260,10 +268,11 @@ export async function* fetchAndInsertFeedData( feedItems: applicationFeedItems, id: feed.id, }; - } catch { + } catch (e) { return { status: "error", id: feed.id, + error: e, }; } }); From f474f0381ae40087d181d93e79c79127806369e3 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:25:15 -0400 Subject: [PATCH 10/29] update subscription dialog --- src/components/feed/SubscriptionDialog.tsx | 15 +++++++++++++-- src/env.js | 10 ++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index a759fe51..4eb75064 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -19,6 +19,7 @@ import { usePlanSuccessStore } from "~/lib/data/plan-success"; import { useSubscription } from "~/lib/data/subscription"; import { orpc } from "~/lib/orpc"; import { authClient, useSession } from "~/lib/auth-client"; +import { env } from "~/env"; export const PLAN_ICONS = { free: SproutIcon, @@ -450,14 +451,24 @@ export function SubscriptionDialog({

Price too high?{" "} + + Let us know + {" "} + or{" "} - Learn how to self-host Serial - + learn how to self-host + {" "} + Serial

); diff --git a/src/env.js b/src/env.js index cd0cb990..30035ee2 100644 --- a/src/env.js +++ b/src/env.js @@ -2,6 +2,14 @@ import { createEnv } from "@t3-oss/env-core"; import { z } from "zod"; export const env = createEnv({ + clientPrefix: "VITE_PUBLIC_", + /** + * Specify your client-side environment variables schema here. + * These are exposed to the browser via Vite's VITE_PUBLIC_ prefix. + */ + client: { + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: z.string().optional(), + }, /** * Specify your server-side environment variables schema here. This way you can ensure the app * isn't built with invalid env vars. @@ -48,6 +56,8 @@ export const env = createEnv({ * middlewares) or client-side so we need to destruct manually. */ runtimeEnv: { + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: import.meta.env + .VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS, DATABASE_URL: process.env.DATABASE_URL, DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN, NODE_ENV: process.env.NODE_ENV, From b920e66d76493e457f6156d15632c64e7a5cb67b Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:00:22 -0400 Subject: [PATCH 11/29] update plans, subscription ui --- package.json | 2 +- src/components/feed/SubscriptionDialog.tsx | 94 ++++++++++++-------- src/env.js | 8 +- src/server/api/routers/subscriptionRouter.ts | 14 +-- src/server/auth/index.tsx | 12 +-- src/server/subscriptions/helpers.ts | 2 +- src/server/subscriptions/plans.ts | 22 ++--- src/server/subscriptions/polar.ts | 4 +- tests/e2e/main-instance/feed-limit.spec.ts | 6 +- 9 files changed, 95 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index 01159cad..c113005c 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:db:test:self-hosted": "turso dev --db-file serial-test-self-hosted.db --port 8082", "dev:migrate:test:main": "DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests pnpm schema:migrate", "dev:migrate:test:self-hosted": "DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests pnpm schema:migrate", - "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", + "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual vite dev --port 3002\"", "dev:test:self-hosted": "concurrently --kill-others \"pnpm dev:db:test:self-hosted\" \"pnpm dev:migrate:test:self-hosted && DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests BETTER_AUTH_BASE_URL=http://localhost:3001 VITE_PUBLIC_IS_MAIN_INSTANCE=false vite dev --port 3001\"", "dev:migrate": "pnpm schema:migrate", "dev:atomic": "vite dev", diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 4eb75064..871b8c50 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -24,7 +24,7 @@ import { env } from "~/env"; export const PLAN_ICONS = { free: SproutIcon, standard: TreeDeciduousIcon, - pro: TreesIcon, + daily: TreesIcon, } as const; function formatPrice(cents: number): string { @@ -34,17 +34,21 @@ function formatPrice(cents: number): string { export function getPlanFeatures(plan: PlanConfig): string[] { const features: string[] = []; - features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); + + if (plan.maxActiveFeeds === Infinity) { + features.push("Unlimited active feeds"); + } else { + features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); + } if (plan.id === "free") { features.push("Refresh up to once an hour"); features.push("Manual refresh only"); - } else { - features.push( - plan.id === "pro" - ? "Refreshes once every 5 min" - : "Refreshes once every 15 min", - ); + } else if (plan.id === "standard") { + features.push("Refreshes once every 15 min"); + features.push("Refresh in background"); + } else if (plan.id === "daily") { + features.push("Refreshes once every 5 min"); features.push("Refresh in background"); } @@ -230,9 +234,9 @@ export function SubscriptionDialog({ const { data: session, refetch: refetchSession } = useSession(); const queryClient = useQueryClient(); const [showVerification, setShowVerification] = useState(false); - const [pendingPlanId, setPendingPlanId] = useState<"standard" | "pro" | null>( - null, - ); + const [pendingPlanId, setPendingPlanId] = useState< + "standard" | "daily" | null + >(null); const [switchPreview, setSwitchPreview] = useState( null, ); @@ -308,7 +312,15 @@ export function SubscriptionDialog({ const isSubscribed = planId !== "free"; - function handleSubscribeClick(id: "standard" | "pro") { + // Only show paid plans that have products configured (or are the user's current plan) + const visiblePlanIds = PLAN_IDS.filter((id) => { + if (id === "free") return true; + if (id === planId) return true; + if (isLoadingProducts) return true; + return products?.some((p) => p.planId === id); + }); + + function handleSubscribeClick(id: "standard" | "daily") { setPendingPlanId(id); if (isSubscribed) { // Already subscribed — show switch confirmation @@ -358,16 +370,24 @@ export function SubscriptionDialog({ onOpenChange={onOpenChange} title="Subscription" description="Choose a plan that fits your needs." - className="lg:max-w-4xl" + className={visiblePlanIds.length <= 3 ? "lg:max-w-4xl" : "lg:max-w-5xl"} headerClassName="lg:text-center" > -
+
{showVerification && !emailVerified && ( -
+
)} - {PLAN_IDS.map((id) => { + {visiblePlanIds.map((id) => { const plan = PLANS[id]; const isCurrent = id === planId; const isPaid = id !== "free"; @@ -449,26 +469,28 @@ export function SubscriptionDialog({ ); })}
-

- Price too high?{" "} - - Let us know - {" "} - or{" "} - - learn how to self-host - {" "} - Serial +

+ Price too high or need higher limits?{" "} + + + Let us know + {" "} + or{" "} + + learn how to self-host + {" "} + Serial +

); diff --git a/src/env.js b/src/env.js index 30035ee2..ed7dcd7d 100644 --- a/src/env.js +++ b/src/env.js @@ -33,8 +33,8 @@ export const env = createEnv({ POLAR_WEBHOOK_SECRET: z.string().optional(), POLAR_STANDARD_MONTHLY_PRODUCT_ID: z.string().optional(), POLAR_STANDARD_ANNUAL_PRODUCT_ID: z.string().optional(), - POLAR_PRO_MONTHLY_PRODUCT_ID: z.string().optional(), - POLAR_PRO_ANNUAL_PRODUCT_ID: z.string().optional(), + POLAR_DAILY_MONTHLY_PRODUCT_ID: z.string().optional(), + POLAR_DAILY_ANNUAL_PRODUCT_ID: z.string().optional(), BACKGROUND_REFRESH_ENABLED: z.string().optional().default("true"), OAUTH_PROVIDER_ID: z.string().optional(), OAUTH_PROVIDER_NAME: z.string().optional(), @@ -73,8 +73,8 @@ export const env = createEnv({ process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID, POLAR_STANDARD_ANNUAL_PRODUCT_ID: process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID, - POLAR_PRO_MONTHLY_PRODUCT_ID: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, - POLAR_PRO_ANNUAL_PRODUCT_ID: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, + POLAR_DAILY_MONTHLY_PRODUCT_ID: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID, + POLAR_DAILY_ANNUAL_PRODUCT_ID: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, BACKGROUND_REFRESH_ENABLED: process.env.BACKGROUND_REFRESH_ENABLED, OAUTH_PROVIDER_ID: process.env.OAUTH_PROVIDER_ID, OAUTH_PROVIDER_NAME: process.env.OAUTH_PROVIDER_NAME, diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index fbfaabf0..2f6fed21 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -72,8 +72,8 @@ export const getProducts = protectedProcedure.handler(async () => { const productIds = [ PLANS.standard.polarMonthlyProductId, PLANS.standard.polarAnnualProductId, - PLANS.pro.polarMonthlyProductId, - PLANS.pro.polarAnnualProductId, + PLANS.daily.polarMonthlyProductId, + PLANS.daily.polarAnnualProductId, ].filter(Boolean); if (productIds.length === 0) { @@ -83,8 +83,12 @@ export const getProducts = protectedProcedure.handler(async () => { try { const results: PlanProduct[] = []; - for (const planId of ["standard", "pro"] as const) { + for (const planId of ["standard", "daily"] as const) { const plan = PLANS[planId]; + + // Skip plans that have no Polar product IDs configured + if (!plan.polarMonthlyProductId && !plan.polarAnnualProductId) continue; + let monthlyPrice: number | null = null; let annualPrice: number | null = null; @@ -144,7 +148,7 @@ export const getProducts = protectedProcedure.handler(async () => { export const createCheckout = protectedProcedure .input( z.object({ - planId: z.enum(["standard", "pro"]), + planId: z.enum(["standard", "daily"]), returnPath: z.string().optional(), }), ) @@ -193,7 +197,7 @@ export const createCheckout = protectedProcedure }); export const previewPlanSwitch = protectedProcedure - .input(z.object({ planId: z.enum(["standard", "pro"]) })) + .input(z.object({ planId: z.enum(["standard", "daily"]) })) .handler(async ({ context, input }) => { if (!IS_BILLING_ENABLED || !polarClient) { return null; diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index 23312927..2a03eb0d 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -84,16 +84,16 @@ function buildPolarPlugin() { slug: "standard-annual", } : null, - process.env.POLAR_PRO_MONTHLY_PRODUCT_ID + process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID ? { - productId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, - slug: "pro-monthly", + productId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID, + slug: "daily-monthly", } : null, - process.env.POLAR_PRO_ANNUAL_PRODUCT_ID + process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID ? { - productId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, - slug: "pro-annual", + productId: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, + slug: "daily-annual", } : null, ].filter(Boolean); diff --git a/src/server/subscriptions/helpers.ts b/src/server/subscriptions/helpers.ts index 8e9b28d5..dfab8dde 100644 --- a/src/server/subscriptions/helpers.ts +++ b/src/server/subscriptions/helpers.ts @@ -20,7 +20,7 @@ export async function getActiveFeedCount(db: DB, userId: string) { } export async function getUserPlanId(userId: string): Promise { - if (!IS_BILLING_ENABLED) return "pro"; + if (!IS_BILLING_ENABLED) return "daily"; const cached = planCache.get(userId); if (cached && Date.now() < cached.expiresAt) { diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index 546c78d1..422e281f 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -1,6 +1,6 @@ import { IS_BILLING_ENABLED } from "./polar"; -export const PLAN_IDS = ["free", "standard", "pro"] as const; +export const PLAN_IDS = ["free", "standard", "daily"] as const; export type PlanId = (typeof PLAN_IDS)[number]; export type PlanConfig = { @@ -16,7 +16,7 @@ export const PLANS: Record = { free: { id: "free", name: "Free", - maxActiveFeeds: 100, + maxActiveFeeds: 40, backgroundRefreshIntervalMs: null, polarMonthlyProductId: null, polarAnnualProductId: null, @@ -24,25 +24,25 @@ export const PLANS: Record = { standard: { id: "standard", name: "Standard", - maxActiveFeeds: 500, + maxActiveFeeds: 400, backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours polarMonthlyProductId: process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID ?? null, }, - pro: { - id: "pro", - name: "Pro", - maxActiveFeeds: 2000, + daily: { + id: "daily", + name: "Daily", + maxActiveFeeds: 1000, backgroundRefreshIntervalMs: 15 * 60 * 1000, // 15 minutes - polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, - polarAnnualProductId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ?? null, + polarMonthlyProductId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID ?? null, + polarAnnualProductId: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID ?? null, }, }; const UNLIMITED_CONFIG: PlanConfig = { - id: "pro", - name: "Pro", + id: "daily", + name: "Daily", maxActiveFeeds: Infinity, backgroundRefreshIntervalMs: 5 * 60 * 1000, polarMonthlyProductId: null, diff --git a/src/server/subscriptions/polar.ts b/src/server/subscriptions/polar.ts index 48b07b55..6d64e0bc 100644 --- a/src/server/subscriptions/polar.ts +++ b/src/server/subscriptions/polar.ts @@ -6,8 +6,8 @@ const REQUIRED_POLAR_ENV_VARS = [ "POLAR_WEBHOOK_SECRET", "POLAR_STANDARD_MONTHLY_PRODUCT_ID", "POLAR_STANDARD_ANNUAL_PRODUCT_ID", - "POLAR_PRO_MONTHLY_PRODUCT_ID", - "POLAR_PRO_ANNUAL_PRODUCT_ID", + "POLAR_DAILY_MONTHLY_PRODUCT_ID", + "POLAR_DAILY_ANNUAL_PRODUCT_ID", ] as const; function hasAllPolarCredentials(): boolean { diff --git a/tests/e2e/main-instance/feed-limit.spec.ts b/tests/e2e/main-instance/feed-limit.spec.ts index b0bb4573..df992788 100644 --- a/tests/e2e/main-instance/feed-limit.spec.ts +++ b/tests/e2e/main-instance/feed-limit.spec.ts @@ -3,8 +3,8 @@ import { signUp } from "../fixtures/auth"; import { MAIN_RSS_SERVER_PORT, MAIN_TURSO_PORT } from "../fixtures/ports"; import { cleanupUser, generateTestEmail } from "../fixtures/seed-db"; -const TOTAL_FEEDS = 110; -const MAX_ACTIVE = 100; +const TOTAL_FEEDS = 50; +const MAX_ACTIVE = 40; const EXPECTED_INACTIVE = TOTAL_FEEDS - MAX_ACTIVE; function generateOpml(count: number): Buffer { @@ -30,7 +30,7 @@ test.describe("feed limit for free plan", () => { } }); - test("importing 110 feeds limits active to 100 and shows upgrade CTA", async ({ + test("importing 50 feeds limits active to 40 and shows upgrade CTA", async ({ page, }) => { test.setTimeout(180_000); From 60686f1273efb5728eb5d556b958b20af862ff20 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:17:29 -0400 Subject: [PATCH 12/29] subscription tweaks --- package.json | 2 +- src/components/feed/SubscriptionDialog.tsx | 30 +++++++++----------- src/env.js | 8 ++++-- src/server/api/routers/subscriptionRouter.ts | 8 ++++-- src/server/auth/index.tsx | 12 ++++++++ src/server/subscriptions/helpers.ts | 2 +- src/server/subscriptions/plans.ts | 16 ++++++++--- src/server/subscriptions/polar.ts | 2 ++ 8 files changed, 53 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index c113005c..7a9247e0 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:db:test:self-hosted": "turso dev --db-file serial-test-self-hosted.db --port 8082", "dev:migrate:test:main": "DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests pnpm schema:migrate", "dev:migrate:test:self-hosted": "DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests pnpm schema:migrate", - "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual vite dev --port 3002\"", + "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", "dev:test:self-hosted": "concurrently --kill-others \"pnpm dev:db:test:self-hosted\" \"pnpm dev:migrate:test:self-hosted && DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests BETTER_AUTH_BASE_URL=http://localhost:3001 VITE_PUBLIC_IS_MAIN_INSTANCE=false vite dev --port 3001\"", "dev:migrate": "pnpm schema:migrate", "dev:atomic": "vite dev", diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 871b8c50..c4f5860a 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon, + CrownIcon, SproutIcon, TreeDeciduousIcon, TreesIcon, @@ -25,6 +26,7 @@ export const PLAN_ICONS = { free: SproutIcon, standard: TreeDeciduousIcon, daily: TreesIcon, + pro: CrownIcon, } as const; function formatPrice(cents: number): string { @@ -50,6 +52,10 @@ export function getPlanFeatures(plan: PlanConfig): string[] { } else if (plan.id === "daily") { features.push("Refreshes once every 5 min"); features.push("Refresh in background"); + } else if (plan.id === "pro") { + features.push("Refreshes every minute"); + features.push("Refresh in background"); + features.push("Priority support"); } return features; @@ -235,7 +241,7 @@ export function SubscriptionDialog({ const queryClient = useQueryClient(); const [showVerification, setShowVerification] = useState(false); const [pendingPlanId, setPendingPlanId] = useState< - "standard" | "daily" | null + "standard" | "daily" | "pro" | null >(null); const [switchPreview, setSwitchPreview] = useState( null, @@ -320,7 +326,7 @@ export function SubscriptionDialog({ return products?.some((p) => p.planId === id); }); - function handleSubscribeClick(id: "standard" | "daily") { + function handleSubscribeClick(id: "standard" | "daily" | "pro") { setPendingPlanId(id); if (isSubscribed) { // Already subscribed — show switch confirmation @@ -370,18 +376,10 @@ export function SubscriptionDialog({ onOpenChange={onOpenChange} title="Subscription" description="Choose a plan that fits your needs." - className={visiblePlanIds.length <= 3 ? "lg:max-w-4xl" : "lg:max-w-5xl"} + className="md:max-w-2xl xl:max-w-6xl" headerClassName="lg:text-center" > -
+
{showVerification && !emailVerified && (
@@ -400,7 +398,7 @@ export function SubscriptionDialog({ return (
@@ -409,7 +407,7 @@ export function SubscriptionDialog({ Current )} -
+
{(() => { const Icon = PLAN_ICONS[id]; @@ -439,7 +437,7 @@ export function SubscriptionDialog({ ))} {isCurrent && isSubscribed && ( -
+
); diff --git a/src/components/admin/UserFeedCountChart.tsx b/src/components/admin/UserFeedCountChart.tsx new file mode 100644 index 00000000..96ee9fc9 --- /dev/null +++ b/src/components/admin/UserFeedCountChart.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +import type { ChartConfig } from "~/components/ui/chart"; +import { orpc } from "~/lib/orpc"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "~/components/ui/chart"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +const chartConfig = { + allUsers: { + label: "All Feeds", + color: "hsl(var(--chart-1))", + }, + activeUsers: { + label: "Active Feeds", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function UserFeedCountChart() { + const { data, isLoading } = useQuery( + orpc.admin.getFeedCountDistribution.queryOptions({ + staleTime: 0, + gcTime: 0, + }), + ); + + return ( + + + + User Feed Count Distribution + + + + {isLoading ? ( +
+ Loading... +
+ ) : !data?.distribution.length ? ( +
+ No data available +
+ ) : ( + + + + + + { + const item = payload[0]; + if (!item) return ""; + return `${item.payload.feedCount} feeds`; + }} + /> + } + /> + + + + + )} +
+
+ ); +} diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index c4f5860a..9af8bfb3 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -1,26 +1,33 @@ "use client"; -import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon, + CircleHelpIcon, CrownIcon, SproutIcon, TreeDeciduousIcon, TreesIcon, } from "lucide-react"; +import { useState } from "react"; import { toast } from "sonner"; import type { PlanConfig } from "~/server/subscriptions/plans"; -import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; import { Skeleton } from "~/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { env } from "~/env"; +import { authClient, useSession } from "~/lib/auth-client"; +import { useFeeds } from "~/lib/data/feeds/store"; import { usePlanSuccessStore } from "~/lib/data/plan-success"; import { useSubscription } from "~/lib/data/subscription"; import { orpc } from "~/lib/orpc"; -import { authClient, useSession } from "~/lib/auth-client"; -import { env } from "~/env"; +import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; export const PLAN_ICONS = { free: SproutIcon, @@ -29,6 +36,15 @@ export const PLAN_ICONS = { pro: CrownIcon, } as const; +const RECOMMENDATION_MESSAGES = { + currentFree: + "You're just getting started with Serial, so no need to give us money just yet! Consider upgrading later when you have more feeds, or if you want feeds to refresh while you're away.", + currentPaid: + "This plan is just right for the number of feeds you have. Good choice!", + upgrade: + "We think this plan is right for you, as it will allow you to keep all your feeds active.", +} as const; + function formatPrice(cents: number): string { const dollars = cents / 100; return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; @@ -317,6 +333,21 @@ export function SubscriptionDialog({ ); const isSubscribed = planId !== "free"; + const feeds = useFeeds(); + + // Recommend the smallest plan that fits the user's feed count, + // but never recommend a lower-tier plan than the user's current plan + const currentPlanIndex = PLAN_IDS.indexOf(planId); + const recommendedPlanId = (() => { + const totalFeeds = feeds.length; + const bestFit = PLAN_IDS.find( + (id) => PLANS[id].maxActiveFeeds >= totalFeeds, + ); + if (!bestFit) return null; + const bestFitIndex = PLAN_IDS.indexOf(bestFit); + if (bestFitIndex < currentPlanIndex) return null; + return bestFit; + })(); // Only show paid plans that have products configured (or are the user's current plan) const visiblePlanIds = PLAN_IDS.filter((id) => { @@ -374,8 +405,8 @@ export function SubscriptionDialog({ @@ -399,13 +430,46 @@ export function SubscriptionDialog({
- {isCurrent && ( - - Current - + {(isCurrent || id === recommendedPlanId) && ( +
+ {isCurrent && ( + + Current + + )} + {id === recommendedPlanId && ( + + + + Recommended + + + + + {isCurrent && id === "free" + ? RECOMMENDATION_MESSAGES.currentFree + : isCurrent + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
)}
@@ -421,7 +485,18 @@ export function SubscriptionDialog({

{monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} {monthlyPrice != null && annualPrice != null && " · "} - {annualPrice != null && `${formatPrice(annualPrice)}/yr`} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))}/mo + + + )}

) : null}
diff --git a/src/components/ui/responsive-dropdown.tsx b/src/components/ui/responsive-dropdown.tsx index e8e35570..32ff2c09 100644 --- a/src/components/ui/responsive-dropdown.tsx +++ b/src/components/ui/responsive-dropdown.tsx @@ -114,7 +114,7 @@ interface ControlledResponsiveDialogProps { onOpenChange: (open: boolean) => void; children: React.ReactNode; title?: string; - description?: string; + description?: React.ReactNode; className?: string; headerClassName?: string; onBack?: () => void; diff --git a/src/server/api/routers/adminRouter.ts b/src/server/api/routers/adminRouter.ts index 815ea06c..19c1cf0f 100644 --- a/src/server/api/routers/adminRouter.ts +++ b/src/server/api/routers/adminRouter.ts @@ -14,7 +14,7 @@ import { isOAuthConfigured } from "~/server/auth/constants"; import { env } from "~/env"; import { protectedProcedure, publicProcedure } from "~/server/orpc/base"; import { auth } from "~/server/auth"; -import { account, appConfig, session, user } from "~/server/db/schema"; +import { account, appConfig, feeds, session, user } from "~/server/db/schema"; import { db } from "~/server/db"; // Admin procedure that requires admin role @@ -698,3 +698,76 @@ export const getRetentionStats = adminProcedure.handler(async () => { return { stats }; }); + +// Get distribution of feed counts per user (how many users have N feeds) +export const getFeedCountDistribution = adminProcedure.handler(async () => { + // Count all feeds per user (subquery) + const allFeedsSq = db + .select({ + userId: feeds.userId, + feedCount: count().as("feed_count"), + }) + .from(feeds) + .groupBy(feeds.userId) + .as("user_feeds"); + + // Aggregate: how many users have each feed count + const allFeedCounts = await db + .select({ + feedCount: allFeedsSq.feedCount, + userCount: count(), + }) + .from(allFeedsSq) + .groupBy(sql`${allFeedsSq.feedCount}`) + .orderBy(sql`${allFeedsSq.feedCount}`) + .all(); + + // Count active feeds per user (subquery) + const activeFeedsSq = db + .select({ + userId: feeds.userId, + feedCount: count().as("feed_count"), + }) + .from(feeds) + .where(eq(feeds.isActive, true)) + .groupBy(feeds.userId) + .as("active_user_feeds"); + + // Aggregate: how many users have each active feed count + const activeFeedCounts = await db + .select({ + feedCount: activeFeedsSq.feedCount, + userCount: count(), + }) + .from(activeFeedsSq) + .groupBy(sql`${activeFeedsSq.feedCount}`) + .orderBy(sql`${activeFeedsSq.feedCount}`) + .all(); + + // Merge into a single array keyed by feedCount + const allMap = new Map(allFeedCounts.map((r) => [r.feedCount, r.userCount])); + const activeMap = new Map( + activeFeedCounts.map((r) => [r.feedCount, r.userCount]), + ); + + const maxFeedCount = Math.max( + ...allFeedCounts.map((r) => r.feedCount), + ...activeFeedCounts.map((r) => r.feedCount), + 0, + ); + + const distribution = []; + for (let i = 1; i <= maxFeedCount; i++) { + const allUsers = allMap.get(i) ?? 0; + const activeUsers = activeMap.get(i) ?? 0; + if (allUsers > 0 || activeUsers > 0) { + distribution.push({ + feedCount: i, + allUsers, + activeUsers, + }); + } + } + + return { distribution }; +}); diff --git a/src/server/api/routers/initialRouter.ts b/src/server/api/routers/initialRouter.ts index a4dbc672..29c32e8b 100644 --- a/src/server/api/routers/initialRouter.ts +++ b/src/server/api/routers/initialRouter.ts @@ -23,7 +23,10 @@ import type { } from "~/server/db/schema"; import type { ORPCContext } from "~/server/orpc/base"; import type { FetchFeedsStatus } from "~/server/rss/fetchFeeds"; -import { getFeedsActivationBudget } from "~/server/subscriptions/helpers"; +import { + checkUserRefreshEligibility, + getFeedsActivationBudget, +} from "~/server/subscriptions/helpers"; import { visibilityFilterSchema } from "~/lib/data/atoms"; import { buildContentTypeFilter, @@ -1410,6 +1413,24 @@ export const requestNewData = protectedProcedure const channel = getUserChannel(context.user.id); const newerThanTimestamp = input.newerThan; + // Check user-level refresh rate limit + const eligibility = await checkUserRefreshEligibility( + context.db, + context.user.id, + ); + if (!eligibility.eligible) { + await publisher.publish(channel, { + source: "new-data", + chunk: { + type: "error", + message: `You can refresh again at ${eligibility.nextFetchAt.toLocaleTimeString()}`, + phase: "initial-fetch", + viewId: -1, + }, + }); + return { status: "rate-limited" }; + } + // Fetch prerequisite data let prerequisiteData: PrerequisiteData; try { diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index d22ceede..b94aa080 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -47,7 +47,7 @@ type PlanProduct = { }; let productsCache: CachedProducts | null = null; -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes +const CACHE_TTL_MS = 30 * 1000; // 30 seconds export const getStatus = protectedProcedure.handler(async ({ context }) => { return getUserPlanLimits(context.db, context.user.id); diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index c2a4f2af..eefb95b2 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -288,6 +288,13 @@ "when": 1775509562655, "tag": "0040_skinny_warstar", "breakpoints": true + }, + { + "idx": 41, + "version": "6", + "when": 1776463859599, + "tag": "0041_last_lady_deathstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 19784369..ee649170 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -49,6 +49,7 @@ export const user = sqliteTable("user", { banned: integer("banned", { mode: "boolean" }).default(false), banReason: text("ban_reason"), banExpires: integer("ban_expires", { mode: "timestamp_ms" }), + nextFetchAt: integer("next_fetch_at", { mode: "timestamp" }), }); export const session = sqliteTable( diff --git a/src/server/subscriptions/helpers.ts b/src/server/subscriptions/helpers.ts index 8e9b28d5..981191a0 100644 --- a/src/server/subscriptions/helpers.ts +++ b/src/server/subscriptions/helpers.ts @@ -3,7 +3,7 @@ import { determinePlanFromProductId, getEffectivePlanConfig } from "./plans"; import { IS_BILLING_ENABLED, polarClient } from "./polar"; import type { PlanId } from "./plans"; import type { db as Database } from "~/server/db"; -import { feeds } from "~/server/db/schema"; +import { feeds, user } from "~/server/db/schema"; type DB = typeof Database; @@ -95,11 +95,42 @@ export async function getUserPlanLimits(db: DB, userId: string) { maxActiveFeeds: config.maxActiveFeeds === Infinity ? -1 : config.maxActiveFeeds, activeFeeds, + refreshIntervalMs: config.refreshIntervalMs, backgroundRefreshIntervalMs: config.backgroundRefreshIntervalMs, billingEnabled: IS_BILLING_ENABLED, }; } +/** + * Check if the user is eligible to refresh based on their plan's refresh interval. + * Returns the next eligible fetch time if they are rate-limited, or null if they can refresh now. + */ +export async function checkUserRefreshEligibility( + db: DB, + userId: string, +): Promise<{ eligible: true } | { eligible: false; nextFetchAt: Date }> { + const planId = await getUserPlanId(userId); + const config = getEffectivePlanConfig(planId); + + const userRow = await db + .select({ nextFetchAt: user.nextFetchAt }) + .from(user) + .where(eq(user.id, userId)) + .get(); + + const now = new Date(); + + if (userRow?.nextFetchAt && userRow.nextFetchAt > now) { + return { eligible: false, nextFetchAt: userRow.nextFetchAt }; + } + + // Update user's nextFetchAt for the next refresh window + const nextFetchAt = new Date(now.getTime() + config.refreshIntervalMs); + await db.update(user).set({ nextFetchAt }).where(eq(user.id, userId)); + + return { eligible: true }; +} + export async function deactivateExcessFeeds( db: DB, userId: string, diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index c3977efc..9444ec01 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -7,6 +7,8 @@ export type PlanConfig = { id: PlanId; name: string; maxActiveFeeds: number; + /** Minimum interval between user-initiated refreshes (server-enforced). */ + refreshIntervalMs: number; backgroundRefreshIntervalMs: number | null; polarMonthlyProductId: string | null; polarAnnualProductId: string | null; @@ -17,6 +19,7 @@ export const PLANS: Record = { id: "free", name: "Free", maxActiveFeeds: 40, + refreshIntervalMs: 60 * 60 * 1000 - 15_000, // 1 hour backgroundRefreshIntervalMs: null, polarMonthlyProductId: null, polarAnnualProductId: null, @@ -24,7 +27,8 @@ export const PLANS: Record = { standard: { id: "standard", name: "Standard", - maxActiveFeeds: 400, + maxActiveFeeds: 200, + refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours polarMonthlyProductId: process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID ?? null, @@ -34,6 +38,7 @@ export const PLANS: Record = { id: "daily", name: "Daily", maxActiveFeeds: 1000, + refreshIntervalMs: 5 * 60 * 1000 - 15_000, // 5 minutes backgroundRefreshIntervalMs: 15 * 60 * 1000, // 15 minutes polarMonthlyProductId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID ?? null, @@ -42,6 +47,7 @@ export const PLANS: Record = { id: "pro", name: "Pro", maxActiveFeeds: 2500, + refreshIntervalMs: 1 * 60 * 1000 - 15_000, // 1 minute backgroundRefreshIntervalMs: 1 * 60 * 1000, // 1 minute polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ?? null, @@ -52,6 +58,7 @@ const UNLIMITED_CONFIG: PlanConfig = { id: "pro", name: "Pro", maxActiveFeeds: Infinity, + refreshIntervalMs: 1 * 60 * 1000 - 15_000, backgroundRefreshIntervalMs: 1 * 60 * 1000, polarMonthlyProductId: null, polarAnnualProductId: null, diff --git a/tests/e2e/main-instance/feed-limit.spec.ts b/tests/e2e/main-instance/feed-limit.spec.ts index df992788..744aa0be 100644 --- a/tests/e2e/main-instance/feed-limit.spec.ts +++ b/tests/e2e/main-instance/feed-limit.spec.ts @@ -85,9 +85,9 @@ test.describe("feed limit for free plan", () => { // Click "Upgrade" in the toast to open subscription dialog await page.getByRole("button", { name: "Upgrade" }).click(); - await expect( - page.getByText("Choose a plan that fits your needs."), - ).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("All prices are taxes-included.")).toBeVisible({ + timeout: 5000, + }); await page.keyboard.press("Escape"); // Navigate to /feeds and verify the counter @@ -101,9 +101,9 @@ test.describe("feed limit for free plan", () => { // Click "Upgrade your plan" button and verify subscription dialog opens await page.getByRole("button", { name: /upgrade your plan/i }).click(); - await expect( - page.getByText("Choose a plan that fits your needs."), - ).toBeVisible({ timeout: 5000 }); + await expect(page.getByText("All prices are taxes-included.")).toBeVisible({ + timeout: 5000, + }); await page.keyboard.press("Escape"); // Verify exactly 10 inactive feed rows (opacity-50 class) From 2f0ddf0484d6cc8e5eb950c0e273265c901e0398 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:10:51 -0400 Subject: [PATCH 14/29] add kv caching, better webhook handling --- package.json | 1 + pnpm-lock.yaml | 73 ++++-- src/app/_app.tsx | 66 ++++- src/components/feed/SubscriptionDialog.tsx | 1 - src/env.js | 4 + src/server/api/routers/subscriptionRouter.ts | 59 ++++- src/server/auth/index.tsx | 122 +++------- src/server/subscriptions/helpers.ts | 54 ++-- src/server/subscriptions/kv.ts | 244 +++++++++++++++++++ src/server/subscriptions/plans.ts | 4 +- src/styles/globals.css | 1 + 11 files changed, 458 insertions(+), 171 deletions(-) create mode 100644 src/server/subscriptions/kv.ts diff --git a/package.json b/package.json index 7a9247e0..a16a558e 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "@tanstack/react-start": "1.167.16", "@tanstack/react-table": "^8.21.3", "@tanstack/zod-adapter": "1.166.9", + "@upstash/redis": "^1.37.0", "better-auth": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb7f61af..77cde425 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 1.13.13(@opentelemetry/api@1.9.1) '@orpc/experimental-publisher': specifier: ^1.13.13 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0) '@orpc/server': specifier: ^1.13.13 version: 1.13.13(@opentelemetry/api@1.9.1)(crossws@0.4.4(srvx@0.11.15))(ws@8.19.0) @@ -68,7 +68,7 @@ importers: version: 3.3.0 '@polar-sh/better-auth': specifier: ^1.8.1 - version: 1.8.3(d0c442ce4a9c66fb669f11db7be7f592) + version: 1.8.3(2d88d33bf391167acd1a24922ffc39b7) '@polar-sh/sdk': specifier: ^0.47.0 version: 0.47.0 @@ -159,9 +159,12 @@ importers: '@tanstack/zod-adapter': specifier: 1.166.9 version: 1.166.9(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(zod@4.3.6) + '@upstash/redis': + specifier: ^1.37.0 + version: 1.37.0 better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -176,7 +179,7 @@ importers: version: 1.11.20 drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6) + version: 0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.4) @@ -339,10 +342,10 @@ importers: version: 0.31.10 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + version: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) drizzle-seed: specifier: ^0.3.1 - version: 0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + version: 0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) esbuild: specifier: ^0.28.0 version: 0.28.0 @@ -360,7 +363,7 @@ importers: version: 7.0.1(eslint@10.2.0(jiti@2.6.1)) nitro: specifier: 3.0.260311-beta - version: 3.0.260311-beta(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.260311-beta(@libsql/client@0.17.2)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) postcss: specifier: ^8.5.8 version: 8.5.8 @@ -3948,6 +3951,9 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.37.0': + resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -7388,6 +7394,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -8637,12 +8646,12 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))': + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))': dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15)': dependencies: @@ -9680,11 +9689,13 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/experimental-publisher@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/experimental-publisher@1.13.13(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)': dependencies: '@orpc/client': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/standard-server': 1.13.13(@opentelemetry/api@1.9.1) + optionalDependencies: + '@upstash/redis': 1.37.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -9782,11 +9793,11 @@ snapshots: dependencies: playwright: 1.59.1 - '@polar-sh/better-auth@1.8.3(d0c442ce4a9c66fb669f11db7be7f592)': + '@polar-sh/better-auth@1.8.3(2d88d33bf391167acd1a24922ffc39b7)': dependencies: '@polar-sh/checkout': 0.2.0(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@stripe/stripe-js@7.9.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) '@polar-sh/sdk': 0.47.0 - better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) zod: 4.3.6 transitivePeerDependencies: - '@stripe/react-stripe-js' @@ -11358,6 +11369,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/redis@1.37.0': + dependencies: + uncrypto: 0.1.3 + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -11622,10 +11637,10 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): + better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15) '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) @@ -11645,7 +11660,7 @@ snapshots: '@tanstack/react-start': 1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) better-sqlite3: 12.8.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -12008,11 +12023,11 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)): + db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)): optionalDependencies: '@libsql/client': 0.17.2 better-sqlite3: 12.8.0 - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) debounce-fn@6.0.0: dependencies: @@ -12131,22 +12146,23 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15): + drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15): optionalDependencies: '@libsql/client': 0.17.2 '@opentelemetry/api': 1.9.1 + '@upstash/redis': 1.37.0 better-sqlite3: 12.8.0 kysely: 0.28.15 - drizzle-seed@0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)): + drizzle-seed@0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)): dependencies: pure-rand: 6.1.0 optionalDependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) - drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6): + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6): dependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) zod: 4.3.6 dunder-proto@1.0.1: @@ -13937,11 +13953,11 @@ snapshots: nf3@0.3.16: {} - nitro@3.0.260311-beta(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.260311-beta(@libsql/client@0.17.2)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.11.15) - db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) env-runner: 0.1.7 h3: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.15)) hookable: 6.1.0 @@ -13952,7 +13968,7 @@ snapshots: rolldown: 1.0.0-rc.12 srvx: 0.11.15 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(@upstash/redis@1.37.0)(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.1 jiti: 2.6.1 @@ -15222,6 +15238,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@7.18.2: {} undici@7.21.0: {} @@ -15311,10 +15329,11 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@2.0.0-alpha.7(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(@upstash/redis@1.37.0)(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3): optionalDependencies: + '@upstash/redis': 1.37.0 chokidar: 4.0.3 - db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) lru-cache: 11.2.7 ofetch: 2.0.0-alpha.3 diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 822c4573..4a91f1bf 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -3,7 +3,7 @@ import "~/styles/globals.css"; import { createFileRoute, Outlet } from "@tanstack/react-router"; import { useQueryClient } from "@tanstack/react-query"; import { CheckIcon } from "lucide-react"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { AppDialogs } from "../components/feed/AppDialogs"; import { Header } from "../components/feed/Header"; import type React from "react"; @@ -38,11 +38,15 @@ export const Route = createFileRoute("/_app")({ }, }); +const MAX_SYNC_ATTEMPTS = 10; +const SYNC_POLL_INTERVAL_MS = 3_000; + function useCheckoutSuccess() { const queryClient = useQueryClient(); const [awaitingUpgrade, setAwaitingUpgrade] = useState(false); const { planId } = useSubscription(); const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); + const previousPlanIdRef = useRef(null); // Detect checkout_success query param on mount useEffect(() => { @@ -56,39 +60,75 @@ function useCheckoutSuccess() { (params.size > 0 ? `?${params.toString()}` : ""); window.history.replaceState({}, "", newUrl); + // Snapshot the current plan so we can detect when it changes + previousPlanIdRef.current = planId; setAwaitingUpgrade(true); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Poll subscription status until the plan upgrades, using refreshStatus - // to bypass the server-side plan cache on each attempt + // Eagerly sync after checkout, then poll if needed useEffect(() => { if (!awaitingUpgrade) return; - if (planId !== "free") { - // Plan is upgraded + const previousPlanId = previousPlanIdRef.current; + + // Check if plan has already changed (e.g. webhook arrived fast) + if (previousPlanId !== null && planId !== previousPlanId) { setAwaitingUpgrade(false); openPlanSuccess(); return; } - // Force-refresh from Polar (cache-busting) every 2s - const poll = async () => { + let attempts = 0; + let cancelled = false; + + const sync = async (): Promise => { try { - const result = await orpcRouterClient.subscription.refreshStatus(); + const result = await orpcRouterClient.subscription.syncAfterCheckout(); // Update the getStatus query data with the fresh result queryClient.setQueryData( orpc.subscription.getStatus.queryOptions().queryKey, result, ); + + const planChanged = + previousPlanId !== null && result.planId !== previousPlanId; + if (planChanged) { + setAwaitingUpgrade(false); + openPlanSuccess(); + return true; + } } catch { - // Ignore errors, will retry on next interval + // Ignore errors, will retry } + return false; }; - void poll(); // Immediately on first run - const interval = setInterval(() => void poll(), 2000); + // First attempt immediately, then poll with interval + void sync().then((done) => { + if (done || cancelled) return; + + const interval = setInterval(() => { + if (cancelled) { + clearInterval(interval); + return; + } + + attempts++; + void sync().then((done) => { + if (done || attempts >= MAX_SYNC_ATTEMPTS) { + clearInterval(interval); + if (!done) { + // Give up gracefully — user will see the upgrade on next load + setAwaitingUpgrade(false); + } + } + }); + }, SYNC_POLL_INTERVAL_MS); + }); - return () => clearInterval(interval); + return () => { + cancelled = true; + }; }, [awaitingUpgrade, planId, queryClient, openPlanSuccess]); return { awaitingUpgrade }; diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 9af8bfb3..bc3e60d3 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -71,7 +71,6 @@ export function getPlanFeatures(plan: PlanConfig): string[] { } else if (plan.id === "pro") { features.push("Refreshes every minute"); features.push("Refresh in background"); - features.push("Priority support"); } return features; diff --git a/src/env.js b/src/env.js index ca36b34d..80d02057 100644 --- a/src/env.js +++ b/src/env.js @@ -37,6 +37,8 @@ export const env = createEnv({ POLAR_DAILY_ANNUAL_PRODUCT_ID: z.string().optional(), POLAR_PRO_MONTHLY_PRODUCT_ID: z.string().optional(), POLAR_PRO_ANNUAL_PRODUCT_ID: z.string().optional(), + UPSTASH_REDIS_REST_URL: z.string().optional(), + UPSTASH_REDIS_REST_TOKEN: z.string().optional(), BACKGROUND_REFRESH_ENABLED: z.string().optional().default("true"), OAUTH_PROVIDER_ID: z.string().optional(), OAUTH_PROVIDER_NAME: z.string().optional(), @@ -79,6 +81,8 @@ export const env = createEnv({ POLAR_DAILY_ANNUAL_PRODUCT_ID: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, POLAR_PRO_MONTHLY_PRODUCT_ID: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, POLAR_PRO_ANNUAL_PRODUCT_ID: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, + UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, + UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, BACKGROUND_REFRESH_ENABLED: process.env.BACKGROUND_REFRESH_ENABLED, OAUTH_PROVIDER_ID: process.env.OAUTH_PROVIDER_ID, OAUTH_PROVIDER_NAME: process.env.OAUTH_PROVIDER_NAME, diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index b94aa080..81e93a6a 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -3,14 +3,18 @@ import { eq } from "drizzle-orm"; import type { PlanId } from "~/server/subscriptions/plans"; import { protectedProcedure } from "~/server/orpc/base"; import { + getUserPlanId, getUserPlanLimits, - invalidatePlanCache, } from "~/server/subscriptions/helpers"; import { IS_BILLING_ENABLED, polarClient } from "~/server/subscriptions/polar"; import { determinePlanFromProductId, PLANS, } from "~/server/subscriptions/plans"; +import { + applySubscriptionSideEffects, + syncPolarDataToKV, +} from "~/server/subscriptions/kv"; import { user } from "~/server/db/schema"; import { IS_EMAIL_ENABLED } from "~/server/email"; @@ -53,9 +57,18 @@ export const getStatus = protectedProcedure.handler(async ({ context }) => { return getUserPlanLimits(context.db, context.user.id); }); -/** Force-refresh the plan from Polar, bypassing the server cache. */ +/** Force-refresh the plan from Polar, bypassing the KV cache. */ export const refreshStatus = protectedProcedure.handler(async ({ context }) => { - invalidatePlanCache(context.user.id); + if (IS_BILLING_ENABLED) { + try { + await syncPolarDataToKV(context.user.id); + } catch (e) { + console.warn( + `[subscription] refreshStatus sync failed for user ${context.user.id}:`, + e, + ); + } + } return getUserPlanLimits(context.db, context.user.id); }); @@ -159,6 +172,12 @@ export const createCheckout = protectedProcedure return { url: null, error: null }; } + // Prevent double-checkout: block if user already has an active paid plan + const existingPlan = await getUserPlanId(context.user.id); + if (existingPlan !== "free") { + return { url: null, error: "already-subscribed" as const }; + } + if (IS_EMAIL_ENABLED) { const currentUser = await context.db .select({ emailVerified: user.emailVerified }) @@ -301,9 +320,43 @@ export const switchPlan = protectedProcedure `[polar] Plan switched for user=${context.user.id} subscription=${input.subscriptionId} newProduct=${input.newProductId}`, ); + // Immediately update KV cache with the new plan + try { + await syncPolarDataToKV(context.user.id); + } catch (e) { + console.warn( + `[polar] Post-switch sync failed for user=${context.user.id}:`, + e, + ); + } + return { success: true }; }); +/** + * Eagerly sync subscription state after checkout completes. + * Called once from the client on checkout success — replaces the old polling approach. + */ +export const syncAfterCheckout = protectedProcedure.handler( + async ({ context }) => { + if (!IS_BILLING_ENABLED) { + return getUserPlanLimits(context.db, context.user.id); + } + + try { + const data = await syncPolarDataToKV(context.user.id); + await applySubscriptionSideEffects(context.db, context.user.id, data); + } catch (e) { + console.warn( + `[subscription] syncAfterCheckout failed for user ${context.user.id}:`, + e, + ); + } + + return getUserPlanLimits(context.db, context.user.id); + }, +); + export const createPortalSession = protectedProcedure.handler( async ({ context }) => { if (!IS_BILLING_ENABLED || !polarClient) { diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index 14351c14..b2e45d91 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -7,16 +7,15 @@ import { APIError, createAuthMiddleware } from "better-auth/api"; import { createMiddleware } from "@tanstack/react-start"; import { getRequestHeaders } from "@tanstack/react-start/server"; import { redirect } from "@tanstack/react-router"; -import { and, asc, count, eq, inArray, sql } from "drizzle-orm"; +import { asc, count, eq } from "drizzle-orm"; import { checkout, polar, portal, webhooks } from "@polar-sh/better-auth"; import { db } from "../db"; -import { account, appConfig, feeds, session, user } from "../db/schema"; -import { determinePlanFromProductId, PLANS } from "../subscriptions/plans"; -import { - deactivateExcessFeeds, - invalidatePlanCache, -} from "../subscriptions/helpers"; +import { account, appConfig, session, user } from "../db/schema"; import { polarClient } from "../subscriptions/polar"; +import { + applySubscriptionSideEffects, + syncPolarDataToKV, +} from "../subscriptions/kv"; import ResetPasswordEmail from "~/emails/reset-password"; import VerifyEmailEmail from "~/emails/verify-email"; import { @@ -53,18 +52,32 @@ export const adminMiddleware = createMiddleware().server(async ({ next }) => { return await next(); }); -async function handleSubscriptionEnd(payload: { +async function syncAndApply(userId: string) { + try { + const data = await syncPolarDataToKV(userId); + await applySubscriptionSideEffects(db, userId, data); + } catch (e) { + console.error( + `[polar webhook] Failed to sync subscription for user ${userId}:`, + e, + ); + } +} + +async function handleSubscriptionWebhook(payload: { data: { customer?: { externalId?: string | null } | null }; }) { - const externalId = payload.data.customer?.externalId; - if (!externalId) return; - - invalidatePlanCache(externalId); - await deactivateExcessFeeds(db, externalId, PLANS.free.maxActiveFeeds); - await db - .update(feeds) - .set({ nextFetchAt: null }) - .where(eq(feeds.userId, externalId)); + const userId = payload.data.customer?.externalId; + if (!userId) return; + await syncAndApply(userId); +} + +async function handleCustomerStateChanged(payload: { + data: { externalId?: string | null }; +}) { + const userId = payload.data.externalId; + if (!userId) return; + await syncAndApply(userId); } function buildPolarPlugin() { @@ -123,74 +136,13 @@ function buildPolarPlugin() { portal(), webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET ?? "", - onSubscriptionActive: async (payload) => { - const externalId = payload.data.customer?.externalId; - console.log( - `[polar webhook] onSubscriptionActive: user=${externalId ?? "unknown"} subscription=${payload.data.id} product=${payload.data.productId}`, - ); - if (!externalId) return; - - invalidatePlanCache(externalId); - - const productId = payload.data.productId; - const planId = determinePlanFromProductId(productId); - if (!planId) { - console.warn(`[polar webhook] Unknown product ID: ${productId}`); - return; - } - - console.log( - `[polar webhook] Resolved plan="${planId}" for user=${externalId}`, - ); - - const config = PLANS[planId]; - if (config.backgroundRefreshIntervalMs) { - // Stagger nextFetchAt across the refresh interval so feeds - // don't all become due at the same instant (thundering herd). - const activeFeeds = await db - .select({ id: feeds.id }) - .from(feeds) - .where( - and(eq(feeds.userId, externalId), eq(feeds.isActive, true)), - ) - .all(); - - const interval = config.backgroundRefreshIntervalMs; - const feedCount = activeFeeds.length; - if (feedCount > 0) { - const nowMs = Date.now(); - const cases = activeFeeds.map((f, i) => { - const offset = - feedCount > 1 ? Math.round((interval / feedCount) * i) : 0; - const ts = Math.floor((nowMs + offset) / 1000); - return sql`WHEN ${f.id} THEN ${ts}`; - }); - await db - .update(feeds) - .set({ - nextFetchAt: sql`(CASE ${feeds.id} ${sql.join(cases, sql` `)} END)`, - }) - .where( - inArray( - feeds.id, - activeFeeds.map((f) => f.id), - ), - ); - } - } - }, - onSubscriptionCanceled: async (payload) => { - console.log( - `[polar webhook] onSubscriptionCanceled: user=${payload.data.customer?.externalId ?? "unknown"} subscription=${payload.data.id}`, - ); - await handleSubscriptionEnd(payload); - }, - onSubscriptionRevoked: async (payload) => { - console.log( - `[polar webhook] onSubscriptionRevoked: user=${payload.data.customer?.externalId ?? "unknown"} subscription=${payload.data.id}`, - ); - await handleSubscriptionEnd(payload); - }, + onSubscriptionCreated: handleSubscriptionWebhook, + onSubscriptionUpdated: handleSubscriptionWebhook, + onSubscriptionActive: handleSubscriptionWebhook, + onSubscriptionCanceled: handleSubscriptionWebhook, + onSubscriptionRevoked: handleSubscriptionWebhook, + onSubscriptionUncanceled: handleSubscriptionWebhook, + onCustomerStateChanged: handleCustomerStateChanged, }), ], }), diff --git a/src/server/subscriptions/helpers.ts b/src/server/subscriptions/helpers.ts index 981191a0..74e5a42f 100644 --- a/src/server/subscriptions/helpers.ts +++ b/src/server/subscriptions/helpers.ts @@ -1,15 +1,13 @@ import { and, asc, count, eq, inArray } from "drizzle-orm"; -import { determinePlanFromProductId, getEffectivePlanConfig } from "./plans"; -import { IS_BILLING_ENABLED, polarClient } from "./polar"; +import { getEffectivePlanConfig } from "./plans"; +import { IS_BILLING_ENABLED } from "./polar"; +import { getSubscriptionFromKV, syncPolarDataToKV } from "./kv"; import type { PlanId } from "./plans"; import type { db as Database } from "~/server/db"; import { feeds, user } from "~/server/db/schema"; type DB = typeof Database; -const PLAN_CACHE_TTL_MS = 60_000; // 1 minute -const planCache = new Map(); - export async function getActiveFeedCount(db: DB, userId: string) { const result = await db .select({ count: count() }) @@ -22,53 +20,29 @@ export async function getActiveFeedCount(db: DB, userId: string) { export async function getUserPlanId(userId: string): Promise { if (!IS_BILLING_ENABLED) return "pro"; - const cached = planCache.get(userId); - if (cached && Date.now() < cached.expiresAt) { - return cached.planId; - } - + // 1. Try KV cache first (fast, shared across instances) try { - // Look up active subscriptions by externalCustomerId (Better Auth sets this to user ID) - const subscriptions = await polarClient!.subscriptions.list({ - externalCustomerId: [userId], - active: true, - }); - - const activeSub = subscriptions.result?.items?.[0]; - const planId: PlanId = activeSub?.productId - ? (determinePlanFromProductId(activeSub.productId) ?? "free") - : "free"; - - planCache.set(userId, { - planId, - expiresAt: Date.now() + PLAN_CACHE_TTL_MS, - }); - - return planId; - } catch (e) { - // On failure, prefer the last-known plan over defaulting to free — - // a Polar outage shouldn't downgrade paid users mid-session. + const cached = await getSubscriptionFromKV(userId); if (cached) { - console.warn( - `[subscription] Polar API failed for user ${userId}, using cached plan "${cached.planId}":`, - e, - ); return cached.planId; } + } catch { + // KV read failed, fall through to sync + } + // 2. KV miss — sync from Polar and write to KV + try { + const data = await syncPolarDataToKV(userId); + return data.planId; + } catch (e) { console.error( - `[subscription] Failed to fetch plan for user ${userId}, no cached value, defaulting to free:`, + `[subscription] Failed to fetch plan for user ${userId}, defaulting to free:`, e, ); return "free"; } } -/** Evict the cached plan for a user (e.g. after a webhook updates their subscription). */ -export function invalidatePlanCache(userId: string) { - planCache.delete(userId); -} - export async function canActivateFeed(db: DB, userId: string) { const planId = await getUserPlanId(userId); const config = getEffectivePlanConfig(planId); diff --git a/src/server/subscriptions/kv.ts b/src/server/subscriptions/kv.ts new file mode 100644 index 00000000..0e41f6d5 --- /dev/null +++ b/src/server/subscriptions/kv.ts @@ -0,0 +1,244 @@ +import { Redis } from "@upstash/redis"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { IS_BILLING_ENABLED, polarClient } from "./polar"; +import { + determinePlanFromProductId, + getEffectivePlanConfig, + PLANS, +} from "./plans"; +import { deactivateExcessFeeds } from "./helpers"; +import type { PlanId } from "./plans"; +import type { db as Database } from "~/server/db"; +import { feeds } from "~/server/db/schema"; + +type DB = typeof Database; + +// --------------------------------------------------------------------------- +// Upstash client — null when billing is disabled or creds are missing. +// Consumers must handle null gracefully (fall through to Polar API). +// --------------------------------------------------------------------------- + +const hasUpstashCredentials = + !!process.env.UPSTASH_REDIS_REST_URL && + !!process.env.UPSTASH_REDIS_REST_TOKEN; + +export const redis = + IS_BILLING_ENABLED && hasUpstashCredentials + ? new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, + }) + : null; + +// --------------------------------------------------------------------------- +// Cached subscription type — stored as JSON at `polar:sub:{userId}` +// --------------------------------------------------------------------------- + +export type PolarSubscriptionCache = { + planId: PlanId; + status: string; // "active" | "trialing" | "past_due" | "canceled" | "none" + subscriptionId: string | null; + productId: string | null; + recurringInterval: string | null; // "month" | "year" + currentPeriodStart: string | null; // ISO string + currentPeriodEnd: string | null; // ISO string + cancelAtPeriodEnd: boolean; + amount: number | null; // cents + currency: string | null; + syncedAt: string; // ISO timestamp of last sync +}; + +function kvKey(userId: string) { + return `polar:sub:${userId}`; +} + +const KV_TTL_SECONDS = 86_400; // 24 hours — safety net; active syncs keep data fresh + +// --------------------------------------------------------------------------- +// syncPolarDataToKV — the single source-of-truth sync function. +// Fetches the latest subscription state from Polar and writes it to KV. +// --------------------------------------------------------------------------- + +export async function syncPolarDataToKV( + userId: string, +): Promise { + if (!polarClient) { + throw new Error( + "[kv] syncPolarDataToKV called but Polar client is not available", + ); + } + + try { + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [userId], + active: true, + }); + + const activeSub = subscriptions.result?.items?.[0]; + + let data: PolarSubscriptionCache; + + if (activeSub?.productId) { + const planId = determinePlanFromProductId(activeSub.productId) ?? "free"; + + data = { + planId, + status: activeSub.status ?? "active", + subscriptionId: activeSub.id ?? null, + productId: activeSub.productId, + recurringInterval: activeSub.recurringInterval ?? null, + currentPeriodStart: activeSub.currentPeriodStart + ? new Date(activeSub.currentPeriodStart).toISOString() + : null, + currentPeriodEnd: activeSub.currentPeriodEnd + ? new Date(activeSub.currentPeriodEnd).toISOString() + : null, + cancelAtPeriodEnd: activeSub.cancelAtPeriodEnd ?? false, + amount: activeSub.amount ?? null, + currency: activeSub.currency ?? null, + syncedAt: new Date().toISOString(), + }; + } else { + data = { + planId: "free", + status: "none", + subscriptionId: null, + productId: null, + recurringInterval: null, + currentPeriodStart: null, + currentPeriodEnd: null, + cancelAtPeriodEnd: false, + amount: null, + currency: null, + syncedAt: new Date().toISOString(), + }; + } + + // Write to KV (best-effort — failure here is non-fatal) + if (redis) { + try { + await redis.set(kvKey(userId), JSON.stringify(data), { + ex: KV_TTL_SECONDS, + }); + } catch (e) { + console.warn( + `[kv] Failed to write subscription cache for user ${userId}:`, + e, + ); + } + } + + return data; + } catch (e) { + // Polar API failed — try to return cached data from KV + if (redis) { + try { + const cached = await getSubscriptionFromKV(userId); + if (cached) { + console.warn( + `[kv] Polar API failed for user ${userId}, using cached data:`, + e, + ); + return cached; + } + } catch { + // KV also failed, fall through + } + } + + console.error( + `[kv] syncPolarDataToKV failed for user ${userId} (no cached fallback):`, + e, + ); + throw e; + } +} + +// --------------------------------------------------------------------------- +// getSubscriptionFromKV — read-only KV lookup. +// Returns null on miss, null redis, or any error. +// --------------------------------------------------------------------------- + +export async function getSubscriptionFromKV( + userId: string, +): Promise { + if (!redis) return null; + + try { + const raw = await redis.get(kvKey(userId)); + if (!raw) return null; + + // @upstash/redis may auto-parse JSON, handle both string and object + const data = + typeof raw === "string" + ? (JSON.parse(raw) as PolarSubscriptionCache) + : (raw as unknown as PolarSubscriptionCache); + + return data; + } catch (e) { + console.warn( + `[kv] Failed to read subscription cache for user ${userId}:`, + e, + ); + return null; + } +} + +// --------------------------------------------------------------------------- +// applySubscriptionSideEffects — business logic that runs after a sync. +// Extracted from the old webhook handlers. +// --------------------------------------------------------------------------- + +const ACTIVE_STATUSES = new Set(["active", "trialing"]); + +export async function applySubscriptionSideEffects( + db: DB, + userId: string, + data: PolarSubscriptionCache, +): Promise { + const config = getEffectivePlanConfig(data.planId); + + if (ACTIVE_STATUSES.has(data.status)) { + // Subscription is active — stagger feed nextFetchAt across the refresh + // interval so feeds don't all become due at the same instant. + if (config.backgroundRefreshIntervalMs) { + const activeFeeds = await db + .select({ id: feeds.id }) + .from(feeds) + .where(and(eq(feeds.userId, userId), eq(feeds.isActive, true))) + .all(); + + const interval = config.backgroundRefreshIntervalMs; + const feedCount = activeFeeds.length; + + if (feedCount > 0) { + const nowMs = Date.now(); + const cases = activeFeeds.map((f, i) => { + const offset = + feedCount > 1 ? Math.round((interval / feedCount) * i) : 0; + const ts = Math.floor((nowMs + offset) / 1000); + return sql`WHEN ${f.id} THEN ${ts}`; + }); + + await db + .update(feeds) + .set({ + nextFetchAt: sql`(CASE ${feeds.id} ${sql.join(cases, sql` `)} END)`, + }) + .where( + inArray( + feeds.id, + activeFeeds.map((f) => f.id), + ), + ); + } + } + } else { + // Subscription ended — deactivate excess feeds, clear nextFetchAt + await deactivateExcessFeeds(db, userId, PLANS.free.maxActiveFeeds); + await db + .update(feeds) + .set({ nextFetchAt: null }) + .where(eq(feeds.userId, userId)); + } +} diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index 9444ec01..10334eb7 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -37,7 +37,7 @@ export const PLANS: Record = { daily: { id: "daily", name: "Daily", - maxActiveFeeds: 1000, + maxActiveFeeds: 500, refreshIntervalMs: 5 * 60 * 1000 - 15_000, // 5 minutes backgroundRefreshIntervalMs: 15 * 60 * 1000, // 15 minutes polarMonthlyProductId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID ?? null, @@ -46,7 +46,7 @@ export const PLANS: Record = { pro: { id: "pro", name: "Pro", - maxActiveFeeds: 2500, + maxActiveFeeds: 2000, refreshIntervalMs: 1 * 60 * 1000 - 15_000, // 1 minute backgroundRefreshIntervalMs: 1 * 60 * 1000, // 1 minute polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, diff --git a/src/styles/globals.css b/src/styles/globals.css index 0d316928..7bb7d776 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -606,6 +606,7 @@ dialog:modal { body, html { @apply bg-background text-foreground; + scrollbar-gutter: stable; } } From eb6ed29ccedd9800deb2f0c2748717bcd0cd00bd Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 13:30:11 -0400 Subject: [PATCH 15/29] dialog tweaks, checkpoint before plan changes --- .env.example | 3 +- package.json | 2 +- src/components/feed/AppDialogs.tsx | 10 ++++ src/components/feed/SubscriptionDialog.tsx | 61 ++++++++++---------- src/components/feed/UserManagementButton.tsx | 9 +-- src/components/ui/responsive-dropdown.tsx | 17 +++++- src/env.js | 5 +- src/server/auth/index.tsx | 4 +- src/server/db/migrations/meta/_journal.json | 9 +-- src/server/subscriptions/plans.ts | 5 +- src/server/subscriptions/polar.ts | 2 +- 11 files changed, 71 insertions(+), 56 deletions(-) diff --git a/.env.example b/.env.example index edee9ac7..e1818cb4 100644 --- a/.env.example +++ b/.env.example @@ -22,9 +22,10 @@ BETTER_AUTH_SECRET= # FROM_EMAIL_ADDRESS is required for email sending to work. # If both provider keys are set, Resend takes priority. FROM_EMAIL_ADDRESS= +VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS= + RESEND_API_KEY= SENDGRID_API_KEY= -VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS= # Integrations INSTAPAPER_OAUTH_ID= diff --git a/package.json b/package.json index a16a558e..b76f42fe 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:db:test:self-hosted": "turso dev --db-file serial-test-self-hosted.db --port 8082", "dev:migrate:test:main": "DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests pnpm schema:migrate", "dev:migrate:test:self-hosted": "DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests pnpm schema:migrate", - "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", + "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", "dev:test:self-hosted": "concurrently --kill-others \"pnpm dev:db:test:self-hosted\" \"pnpm dev:migrate:test:self-hosted && DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests BETTER_AUTH_BASE_URL=http://localhost:3001 VITE_PUBLIC_IS_MAIN_INSTANCE=false vite dev --port 3001\"", "dev:migrate": "pnpm schema:migrate", "dev:atomic": "vite dev", diff --git a/src/components/feed/AppDialogs.tsx b/src/components/feed/AppDialogs.tsx index 0b618853..ad04cb84 100644 --- a/src/components/feed/AppDialogs.tsx +++ b/src/components/feed/AppDialogs.tsx @@ -1,3 +1,5 @@ +import { useDialogStore } from "./dialogStore"; +import { SubscriptionDialog } from "./SubscriptionDialog"; import { UserProfileEditDialog } from "./UserProfileEditDialog"; import { AddContentCategoryDialog } from "~/components/AddContentCategoryDialog"; import { AddFeedDialog } from "~/components/AddFeedDialog"; @@ -6,6 +8,8 @@ import { ConnectionsDialog } from "~/components/ConnectionsDialog"; import { CustomVideoDialog } from "~/components/CustomVideoDialog"; export function AppDialogs() { + const { dialog, closeDialog } = useDialogStore(); + return ( <> @@ -14,6 +18,12 @@ export function AppDialogs() { + { + if (!open) closeDialog(); + }} + /> ); } diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index bc3e60d3..964ff7da 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -4,10 +4,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckIcon, CircleHelpIcon, - CrownIcon, + ShrubIcon, SproutIcon, TreeDeciduousIcon, - TreesIcon, + TreePineIcon, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -31,9 +31,9 @@ import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; export const PLAN_ICONS = { free: SproutIcon, - standard: TreeDeciduousIcon, - daily: TreesIcon, - pro: CrownIcon, + standard: ShrubIcon, + daily: TreeDeciduousIcon, + pro: TreePineIcon, } as const; const RECOMMENDATION_MESSAGES = { @@ -407,7 +407,33 @@ export function SubscriptionDialog({ title="Subscribe to Serial" description="All prices are taxes-included." className="md:max-w-2xl xl:max-w-6xl" - headerClassName="lg:text-center" + headerClassName="md:text-center" + footerBorder={false} + footer={ +

+ Price too high or need higher limits?{" "} + + + Let us know + {" "} + or{" "} + + learn how to self-host + {" "} + Serial + +

+ } >
{showVerification && !emailVerified && ( @@ -541,29 +567,6 @@ export function SubscriptionDialog({ ); })}
-

- Price too high or need higher limits?{" "} - - - Let us know - {" "} - or{" "} - - learn how to self-host - {" "} - Serial - -

); } diff --git a/src/components/feed/UserManagementButton.tsx b/src/components/feed/UserManagementButton.tsx index d694ca29..a0bead6d 100644 --- a/src/components/feed/UserManagementButton.tsx +++ b/src/components/feed/UserManagementButton.tsx @@ -10,7 +10,6 @@ import { } from "lucide-react"; import { useState } from "react"; import { useDialogStore } from "./dialogStore"; -import { SubscriptionDialog } from "./SubscriptionDialog"; import { Button } from "~/components/ui/button"; import { DropdownMenuSeparator } from "~/components/ui/dropdown-menu"; import { @@ -33,7 +32,7 @@ export function UserManagementNavItem() { isPending, // loading state } = authClient.useSession(); - const { launchDialog, closeDialog, dialog } = useDialogStore(); + const { launchDialog } = useDialogStore(); const { billingEnabled, planName } = useSubscription(); const router = useRouter(); @@ -43,12 +42,6 @@ export function UserManagementNavItem() { return ( - { - if (!open) closeDialog(); - }} - /> void; headerRight?: React.ReactNode; footer?: React.ReactNode; + footerBorder?: boolean; onOpenAutoFocus?: (event: Event) => void; } export function ControlledResponsiveDialog({ @@ -133,6 +134,7 @@ export function ControlledResponsiveDialog({ className, headerClassName, footer, + footerBorder = true, onOpenAutoFocus, }: ControlledResponsiveDialogProps) { const isDesktop = useMediaQuery("(min-width: 640px)"); @@ -170,7 +172,11 @@ export function ControlledResponsiveDialog({
{children}
- {footer &&
{footer}
} + {footer && ( +
+ {footer} +
+ )} ); @@ -199,7 +205,14 @@ export function ControlledResponsiveDialog({ {children}
{footer && ( -
{footer}
+
+ {footer} +
)} {!footer &&
} diff --git a/src/env.js b/src/env.js index 80d02057..13c42b41 100644 --- a/src/env.js +++ b/src/env.js @@ -36,7 +36,7 @@ export const env = createEnv({ POLAR_DAILY_MONTHLY_PRODUCT_ID: z.string().optional(), POLAR_DAILY_ANNUAL_PRODUCT_ID: z.string().optional(), POLAR_PRO_MONTHLY_PRODUCT_ID: z.string().optional(), - POLAR_PRO_ANNUAL_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID: z.string().optional(), UPSTASH_REDIS_REST_URL: z.string().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().optional(), BACKGROUND_REFRESH_ENABLED: z.string().optional().default("true"), @@ -80,7 +80,8 @@ export const env = createEnv({ POLAR_DAILY_MONTHLY_PRODUCT_ID: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID, POLAR_DAILY_ANNUAL_PRODUCT_ID: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, POLAR_PRO_MONTHLY_PRODUCT_ID: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, - POLAR_PRO_ANNUAL_PRODUCT_ID: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, + POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID: + process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, BACKGROUND_REFRESH_ENABLED: process.env.BACKGROUND_REFRESH_ENABLED, diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index b2e45d91..74497874 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -115,9 +115,9 @@ function buildPolarPlugin() { slug: "pro-monthly", } : null, - process.env.POLAR_PRO_ANNUAL_PRODUCT_ID + process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID ? { - productId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, + productId: process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, slug: "pro-annual", } : null, diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index eefb95b2..75c3475c 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -288,13 +288,6 @@ "when": 1775509562655, "tag": "0040_skinny_warstar", "breakpoints": true - }, - { - "idx": 41, - "version": "6", - "when": 1776463859599, - "tag": "0041_last_lady_deathstrike", - "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index 10334eb7..0e6fafc9 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -46,11 +46,12 @@ export const PLANS: Record = { pro: { id: "pro", name: "Pro", - maxActiveFeeds: 2000, + maxActiveFeeds: 1000, refreshIntervalMs: 1 * 60 * 1000 - 15_000, // 1 minute backgroundRefreshIntervalMs: 1 * 60 * 1000, // 1 minute polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, - polarAnnualProductId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ?? null, + polarAnnualProductId: + process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID ?? null, }, }; diff --git a/src/server/subscriptions/polar.ts b/src/server/subscriptions/polar.ts index 7fbca737..5e4c29c5 100644 --- a/src/server/subscriptions/polar.ts +++ b/src/server/subscriptions/polar.ts @@ -9,7 +9,7 @@ const REQUIRED_POLAR_ENV_VARS = [ "POLAR_DAILY_MONTHLY_PRODUCT_ID", "POLAR_DAILY_ANNUAL_PRODUCT_ID", "POLAR_PRO_MONTHLY_PRODUCT_ID", - "POLAR_PRO_ANNUAL_PRODUCT_ID", + "POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID", ] as const; function hasAllPolarCredentials(): boolean { From ad4c6c307fd6257fb969c8fbfb331c4a21a04fc8 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 15:57:37 -0400 Subject: [PATCH 16/29] good subscription dialog state --- package.json | 2 +- src/app/_app.tsx | 17 +- src/components/feed/SubscriptionDialog.tsx | 537 ++++++++++++++----- src/env.js | 31 +- src/server/api/routers/subscriptionRouter.ts | 37 +- src/server/auth/index.tsx | 40 +- src/server/subscriptions/plans.ts | 51 +- src/server/subscriptions/polar.ts | 12 +- src/styles/globals.css | 1 - 9 files changed, 515 insertions(+), 213 deletions(-) diff --git a/package.json b/package.json index b76f42fe..69085334 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:db:test:self-hosted": "turso dev --db-file serial-test-self-hosted.db --port 8082", "dev:migrate:test:main": "DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests pnpm schema:migrate", "dev:migrate:test:self-hosted": "DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests pnpm schema:migrate", - "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_DAILY_MONTHLY_PRODUCT_ID=test-daily-monthly POLAR_DAILY_ANNUAL_PRODUCT_ID=test-daily-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", + "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID=test-standard-small-monthly POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID=test-standard-small-annual POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID=test-standard-medium-monthly POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID=test-standard-medium-annual POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID=test-standard-large-monthly POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID=test-standard-large-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", "dev:test:self-hosted": "concurrently --kill-others \"pnpm dev:db:test:self-hosted\" \"pnpm dev:migrate:test:self-hosted && DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests BETTER_AUTH_BASE_URL=http://localhost:3001 VITE_PUBLIC_IS_MAIN_INSTANCE=false vite dev --port 3001\"", "dev:migrate": "pnpm schema:migrate", "dev:atomic": "vite dev", diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 4a91f1bf..7b8032f6 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -44,12 +44,13 @@ const SYNC_POLL_INTERVAL_MS = 3_000; function useCheckoutSuccess() { const queryClient = useQueryClient(); const [awaitingUpgrade, setAwaitingUpgrade] = useState(false); - const { planId } = useSubscription(); + const { planId, billingEnabled } = useSubscription(); const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); const previousPlanIdRef = useRef(null); // Detect checkout_success query param on mount useEffect(() => { + if (!billingEnabled) return; const params = new URLSearchParams(window.location.search); if (params.get("checkout_success") !== "true") return; @@ -131,7 +132,7 @@ function useCheckoutSuccess() { }; }, [awaitingUpgrade, planId, queryClient, openPlanSuccess]); - return { awaitingUpgrade }; + return { awaitingUpgrade, billingEnabled }; } function CheckoutSuccessDialog({ @@ -178,7 +179,7 @@ function CheckoutSuccessDialog({ function RootLayout() { const { mostRecentRelease } = Route.useLoaderData(); useAltKeyHeld(); - const { awaitingUpgrade } = useCheckoutSuccess(); + const { awaitingUpgrade, billingEnabled } = useCheckoutSuccess(); const showPlanSuccess = usePlanSuccessStore((s) => s.showDialog); const closePlanSuccess = usePlanSuccessStore((s) => s.closeDialog); @@ -207,10 +208,12 @@ function RootLayout() {
- + {billingEnabled && ( + + )} diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 964ff7da..39fbb2e0 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -8,6 +8,7 @@ import { SproutIcon, TreeDeciduousIcon, TreePineIcon, + TreesIcon, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -31,11 +32,29 @@ import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; export const PLAN_ICONS = { free: SproutIcon, - standard: ShrubIcon, - daily: TreeDeciduousIcon, - pro: TreePineIcon, + "standard-small": ShrubIcon, + "standard-medium": TreeDeciduousIcon, + "standard-large": TreePineIcon, + pro: TreesIcon, } as const; +const QUOTA_DISPLAY_NAMES = { + "standard-small": "Small", + "standard-medium": "Medium", + "standard-large": "Large", +} as const; + +const STANDARD_FEATURES = [ + "Refreshes once every 15 min", + "Refresh in background", +] as const; + +const STANDARD_PLAN_IDS = [ + "standard-small", + "standard-medium", + "standard-large", +] as const; + const RECOMMENDATION_MESSAGES = { currentFree: "You're just getting started with Serial, so no need to give us money just yet! Consider upgrading later when you have more feeds, or if you want feeds to refresh while you're away.", @@ -62,12 +81,13 @@ export function getPlanFeatures(plan: PlanConfig): string[] { if (plan.id === "free") { features.push("Refresh up to once an hour"); features.push("Manual refresh only"); - } else if (plan.id === "standard") { + } else if ( + plan.id === "standard-small" || + plan.id === "standard-medium" || + plan.id === "standard-large" + ) { features.push("Refreshes once every 15 min"); features.push("Refresh in background"); - } else if (plan.id === "daily") { - features.push("Refreshes once every 5 min"); - features.push("Refresh in background"); } else if (plan.id === "pro") { features.push("Refreshes every minute"); features.push("Refresh in background"); @@ -172,6 +192,212 @@ type SwitchPreview = { newProductId: string; }; +function FreePlanCard({ + planId, + recommendedPlanId, +}: { + planId: string; + recommendedPlanId: string | null; +}) { + const plan = PLANS.free; + const isCurrent = planId === "free"; + const isRecommended = recommendedPlanId === "free"; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS.free; + + return ( +
+ {(isCurrent || isRecommended) && ( +
+ {isCurrent && ( + + Current + + )} + {isRecommended && ( + + + + Recommended + + + + + {isCurrent + ? RECOMMENDATION_MESSAGES.currentFree + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+ +

{plan.name}

+
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ ); +} + +function ProPlanCard({ + planId, + recommendedPlanId, + products, + isLoadingProducts, + isSubscribed, + onSubscribeClick, + portalMutation, + checkoutMutation, + previewMutation, +}: { + planId: string; + recommendedPlanId: string | null; + products: + | Array<{ + planId: string; + monthlyPrice: number | null; + annualPrice: number | null; + }> + | undefined; + isLoadingProducts: boolean; + isSubscribed: boolean; + onSubscribeClick: (id: "pro") => void; + portalMutation: any; + checkoutMutation: any; + previewMutation: any; +}) { + const plan = PLANS.pro; + const isCurrent = planId === "pro"; + const isRecommended = recommendedPlanId === "pro"; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS.pro; + const product = products?.find((p) => p.planId === "pro"); + const monthlyPrice = product?.monthlyPrice ?? null; + const annualPrice = product?.annualPrice ?? null; + const hasPrice = monthlyPrice != null || annualPrice != null; + + return ( +
+ {(isCurrent || isRecommended) && ( +
+ {isCurrent && ( + + Current + + )} + {isRecommended && ( + + + + Recommended + + + + + {isCurrent + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+
+ +

{plan.name}

+
+ {isLoadingProducts ? ( + + ) : hasPrice ? ( +

+ {monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} + {monthlyPrice != null && annualPrice != null && " · "} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))}/mo + + + )} +

+ ) : null} +
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {isCurrent && isSubscribed ? ( +
+ +
+ ) : !isCurrent ? ( +
+ +
+ ) : null} +
+ ); +} + function PlanSwitchConfirmation({ preview, onBack, @@ -244,6 +470,17 @@ function PlanSwitchConfirmation({ ); } +function getRecommendedPlanId( + totalFeeds: number, + currentPlanIndex: number, +): string | null { + const bestFit = PLAN_IDS.find((id) => PLANS[id].maxActiveFeeds >= totalFeeds); + if (!bestFit) return null; + const bestFitIndex = PLAN_IDS.indexOf(bestFit); + if (bestFitIndex < currentPlanIndex) return null; + return bestFit; +} + export function SubscriptionDialog({ open, onOpenChange, @@ -256,7 +493,7 @@ export function SubscriptionDialog({ const queryClient = useQueryClient(); const [showVerification, setShowVerification] = useState(false); const [pendingPlanId, setPendingPlanId] = useState< - "standard" | "daily" | "pro" | null + "standard-small" | "standard-medium" | "standard-large" | "pro" | null >(null); const [switchPreview, setSwitchPreview] = useState( null, @@ -267,7 +504,6 @@ export function SubscriptionDialog({ const { data: products, isLoading: isLoadingProducts } = useQuery({ ...orpc.subscription.getProducts.queryOptions(), enabled: open, - staleTime: 5 * 60 * 1000, }); const checkoutMutation = useMutation( @@ -337,26 +573,14 @@ export function SubscriptionDialog({ // Recommend the smallest plan that fits the user's feed count, // but never recommend a lower-tier plan than the user's current plan const currentPlanIndex = PLAN_IDS.indexOf(planId); - const recommendedPlanId = (() => { - const totalFeeds = feeds.length; - const bestFit = PLAN_IDS.find( - (id) => PLANS[id].maxActiveFeeds >= totalFeeds, - ); - if (!bestFit) return null; - const bestFitIndex = PLAN_IDS.indexOf(bestFit); - if (bestFitIndex < currentPlanIndex) return null; - return bestFit; - })(); - - // Only show paid plans that have products configured (or are the user's current plan) - const visiblePlanIds = PLAN_IDS.filter((id) => { - if (id === "free") return true; - if (id === planId) return true; - if (isLoadingProducts) return true; - return products?.some((p) => p.planId === id); - }); + const recommendedPlanId = getRecommendedPlanId( + feeds.length, + currentPlanIndex, + ); - function handleSubscribeClick(id: "standard" | "daily" | "pro") { + function handleSubscribeClick( + id: "standard-small" | "standard-medium" | "standard-large" | "pro", + ) { setPendingPlanId(id); if (isSubscribed) { // Already subscribed — show switch confirmation @@ -406,8 +630,8 @@ export function SubscriptionDialog({ onOpenChange={onOpenChange} title="Subscribe to Serial" description="All prices are taxes-included." - className="md:max-w-2xl xl:max-w-6xl" - headerClassName="md:text-center" + className="lg:max-w-5xl xl:max-w-6xl" + headerClassName="lg:text-center" footerBorder={false} footer={

@@ -435,137 +659,152 @@ export function SubscriptionDialog({

} > -
+
{showVerification && !emailVerified && (
)} - {visiblePlanIds.map((id) => { - const plan = PLANS[id]; - const isCurrent = id === planId; - const isPaid = id !== "free"; - const product = products?.find((p) => p.planId === id); - const monthlyPrice = product?.monthlyPrice ?? null; - const annualPrice = product?.annualPrice ?? null; - const hasPrice = monthlyPrice != null || annualPrice != null; - const features = getPlanFeatures(plan); - - return ( -
- {(isCurrent || id === recommendedPlanId) && ( + + {/* Free plan */} + + + {/* Paid plans */} +
+
+ +

Standard

+
+
    + {STANDARD_FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {STANDARD_PLAN_IDS.map((id) => { + const plan = PLANS[id]; + const isCurrent = id === planId; + const isRecommended = id === recommendedPlanId; + const product = products?.find((p) => p.planId === id); + const monthlyPrice = product?.monthlyPrice ?? null; + const annualPrice = product?.annualPrice ?? null; + const hasPrice = monthlyPrice != null || annualPrice != null; + const feedsLabel = `Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`; + + return (
- {isCurrent && ( - - Current - - )} - {id === recommendedPlanId && ( - - - - Recommended - + {(isCurrent || isRecommended) && ( +
+ {isCurrent && ( + + Current - - - {isCurrent && id === "free" - ? RECOMMENDATION_MESSAGES.currentFree - : isCurrent - ? RECOMMENDATION_MESSAGES.currentPaid - : RECOMMENDATION_MESSAGES.upgrade} - - + )} + {isRecommended && ( + + + + Recommended + + + + + {isCurrent + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
)} -
- )} -
-
- {(() => { - const Icon = PLAN_ICONS[id]; - return ; - })()} -

{plan.name}

-
- {isPaid && isLoadingProducts ? ( - - ) : hasPrice ? ( -

- {monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} - {monthlyPrice != null && annualPrice != null && " · "} - {annualPrice != null && ( - - - - {formatPrice(annualPrice)}/yr - - - - {formatPrice(Math.round(annualPrice / 12))}/mo - - - )} +

+ + {QUOTA_DISPLAY_NAMES[id]} + +
+ {isLoadingProducts ? ( + + ) : hasPrice ? ( + + {monthlyPrice != null && + `${formatPrice(monthlyPrice)}/mo`} + {monthlyPrice != null && annualPrice != null && " · "} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))} + /mo + + + )} + + ) : null} + {isCurrent && isSubscribed ? ( + + ) : !isCurrent ? ( + + ) : null} +
+
+

+ {feedsLabel}

- ) : null} -
-
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- {isCurrent && isSubscribed && ( -
-
- )} - {!isCurrent && id !== "free" && ( -
- -
- )} -
- ); - })} + ); + })} +
+
+ + {/* Pro plan */} +
); diff --git a/src/env.js b/src/env.js index 13c42b41..b3c54a6d 100644 --- a/src/env.js +++ b/src/env.js @@ -31,12 +31,14 @@ export const env = createEnv({ INSTAPAPER_OAUTH_SECRET: z.string().optional(), POLAR_ACCESS_TOKEN: z.string().optional(), POLAR_WEBHOOK_SECRET: z.string().optional(), - POLAR_STANDARD_MONTHLY_PRODUCT_ID: z.string().optional(), - POLAR_STANDARD_ANNUAL_PRODUCT_ID: z.string().optional(), - POLAR_DAILY_MONTHLY_PRODUCT_ID: z.string().optional(), - POLAR_DAILY_ANNUAL_PRODUCT_ID: z.string().optional(), - POLAR_PRO_MONTHLY_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID: z.string().optional(), + POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID: z.string().optional(), POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID: z.string().optional(), + POLAR_PRO_MONTHLY_PRODUCT_ID: z.string().optional(), + POLAR_PRO_ANNUAL_PRODUCT_ID: z.string().optional(), UPSTASH_REDIS_REST_URL: z.string().optional(), UPSTASH_REDIS_REST_TOKEN: z.string().optional(), BACKGROUND_REFRESH_ENABLED: z.string().optional().default("true"), @@ -73,15 +75,20 @@ export const env = createEnv({ INSTAPAPER_OAUTH_SECRET: process.env.INSTAPAPER_OAUTH_SECRET, POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN, POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET, - POLAR_STANDARD_MONTHLY_PRODUCT_ID: - process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID, - POLAR_STANDARD_ANNUAL_PRODUCT_ID: - process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID, - POLAR_DAILY_MONTHLY_PRODUCT_ID: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID, - POLAR_DAILY_ANNUAL_PRODUCT_ID: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, - POLAR_PRO_MONTHLY_PRODUCT_ID: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, + POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID: + process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID, + POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID: + process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID, + POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID: + process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID, + POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID: + process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID, + POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID: + process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID, POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID: process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, + POLAR_PRO_MONTHLY_PRODUCT_ID: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, + POLAR_PRO_ANNUAL_PRODUCT_ID: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN, BACKGROUND_REFRESH_ENABLED: process.env.BACKGROUND_REFRESH_ENABLED, diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 81e93a6a..0fe7b110 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -51,7 +51,7 @@ type PlanProduct = { }; let productsCache: CachedProducts | null = null; -const CACHE_TTL_MS = 30 * 1000; // 30 seconds +const CACHE_TTL_MS = 5 * 1000; // 5 seconds export const getStatus = protectedProcedure.handler(async ({ context }) => { return getUserPlanLimits(context.db, context.user.id); @@ -83,10 +83,12 @@ export const getProducts = protectedProcedure.handler(async () => { } const productIds = [ - PLANS.standard.polarMonthlyProductId, - PLANS.standard.polarAnnualProductId, - PLANS.daily.polarMonthlyProductId, - PLANS.daily.polarAnnualProductId, + PLANS["standard-small"].polarMonthlyProductId, + PLANS["standard-small"].polarAnnualProductId, + PLANS["standard-medium"].polarMonthlyProductId, + PLANS["standard-medium"].polarAnnualProductId, + PLANS["standard-large"].polarMonthlyProductId, + PLANS["standard-large"].polarAnnualProductId, PLANS.pro.polarMonthlyProductId, PLANS.pro.polarAnnualProductId, ].filter(Boolean); @@ -98,7 +100,12 @@ export const getProducts = protectedProcedure.handler(async () => { try { const results: PlanProduct[] = []; - for (const planId of ["standard", "daily", "pro"] as const) { + for (const planId of [ + "standard-small", + "standard-medium", + "standard-large", + "pro", + ] as const) { const plan = PLANS[planId]; // Skip plans that have no Polar product IDs configured @@ -163,7 +170,12 @@ export const getProducts = protectedProcedure.handler(async () => { export const createCheckout = protectedProcedure .input( z.object({ - planId: z.enum(["standard", "daily", "pro"]), + planId: z.enum([ + "standard-small", + "standard-medium", + "standard-large", + "pro", + ]), returnPath: z.string().optional(), }), ) @@ -218,7 +230,16 @@ export const createCheckout = protectedProcedure }); export const previewPlanSwitch = protectedProcedure - .input(z.object({ planId: z.enum(["standard", "daily", "pro"]) })) + .input( + z.object({ + planId: z.enum([ + "standard-small", + "standard-medium", + "standard-large", + "pro", + ]), + }), + ) .handler(async ({ context, input }) => { if (!IS_BILLING_ENABLED || !polarClient) { return null; diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index 74497874..bb4a3412 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -85,28 +85,40 @@ function buildPolarPlugin() { if (!process.env.POLAR_WEBHOOK_SECRET) return []; const products = [ - process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID + process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID ? { - productId: process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID, - slug: "standard-monthly", + productId: process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID, + slug: "standard-small-monthly", } : null, - process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID + process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID ? { - productId: process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID, - slug: "standard-annual", + productId: process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID, + slug: "standard-small-annual", } : null, - process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID + process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID ? { - productId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID, - slug: "daily-monthly", + productId: process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID, + slug: "standard-medium-monthly", } : null, - process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID + process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID ? { - productId: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID, - slug: "daily-annual", + productId: process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID, + slug: "standard-medium-annual", + } + : null, + process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID + ? { + productId: process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID, + slug: "standard-large-monthly", + } + : null, + process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID + ? { + productId: process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, + slug: "standard-large-annual", } : null, process.env.POLAR_PRO_MONTHLY_PRODUCT_ID @@ -115,9 +127,9 @@ function buildPolarPlugin() { slug: "pro-monthly", } : null, - process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID + process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ? { - productId: process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, + productId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, slug: "pro-annual", } : null, diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index 0e6fafc9..6ec00003 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -1,6 +1,12 @@ import { IS_BILLING_ENABLED } from "./polar"; -export const PLAN_IDS = ["free", "standard", "daily", "pro"] as const; +export const PLAN_IDS = [ + "free", + "standard-small", + "standard-medium", + "standard-large", + "pro", +] as const; export type PlanId = (typeof PLAN_IDS)[number]; export type PlanConfig = { @@ -24,34 +30,47 @@ export const PLANS: Record = { polarMonthlyProductId: null, polarAnnualProductId: null, }, - standard: { - id: "standard", - name: "Standard", + "standard-small": { + id: "standard-small", + name: "Small", maxActiveFeeds: 200, refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours polarMonthlyProductId: - process.env.POLAR_STANDARD_MONTHLY_PRODUCT_ID ?? null, - polarAnnualProductId: process.env.POLAR_STANDARD_ANNUAL_PRODUCT_ID ?? null, + process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID ?? null, + polarAnnualProductId: + process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID ?? null, }, - daily: { - id: "daily", - name: "Daily", + "standard-medium": { + id: "standard-medium", + name: "Medium", maxActiveFeeds: 500, - refreshIntervalMs: 5 * 60 * 1000 - 15_000, // 5 minutes - backgroundRefreshIntervalMs: 15 * 60 * 1000, // 15 minutes - polarMonthlyProductId: process.env.POLAR_DAILY_MONTHLY_PRODUCT_ID ?? null, - polarAnnualProductId: process.env.POLAR_DAILY_ANNUAL_PRODUCT_ID ?? null, + refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes + backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours + polarMonthlyProductId: + process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID ?? null, + polarAnnualProductId: + process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID ?? null, + }, + "standard-large": { + id: "standard-large", + name: "Large", + maxActiveFeeds: 1000, + refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes + backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours + polarMonthlyProductId: + process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID ?? null, + polarAnnualProductId: + process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID ?? null, }, pro: { id: "pro", name: "Pro", - maxActiveFeeds: 1000, + maxActiveFeeds: 2500, refreshIntervalMs: 1 * 60 * 1000 - 15_000, // 1 minute backgroundRefreshIntervalMs: 1 * 60 * 1000, // 1 minute polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, - polarAnnualProductId: - process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID ?? null, + polarAnnualProductId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ?? null, }, }; diff --git a/src/server/subscriptions/polar.ts b/src/server/subscriptions/polar.ts index 5e4c29c5..cfa59327 100644 --- a/src/server/subscriptions/polar.ts +++ b/src/server/subscriptions/polar.ts @@ -4,12 +4,14 @@ import { IS_MAIN_INSTANCE } from "~/lib/constants"; const REQUIRED_POLAR_ENV_VARS = [ "POLAR_ACCESS_TOKEN", "POLAR_WEBHOOK_SECRET", - "POLAR_STANDARD_MONTHLY_PRODUCT_ID", - "POLAR_STANDARD_ANNUAL_PRODUCT_ID", - "POLAR_DAILY_MONTHLY_PRODUCT_ID", - "POLAR_DAILY_ANNUAL_PRODUCT_ID", - "POLAR_PRO_MONTHLY_PRODUCT_ID", + "POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID", + "POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID", + "POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID", + "POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID", + "POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID", "POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID", + "POLAR_PRO_MONTHLY_PRODUCT_ID", + "POLAR_PRO_ANNUAL_PRODUCT_ID", ] as const; function hasAllPolarCredentials(): boolean { diff --git a/src/styles/globals.css b/src/styles/globals.css index 7bb7d776..0d316928 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -606,7 +606,6 @@ dialog:modal { body, html { @apply bg-background text-foreground; - scrollbar-gutter: stable; } } From 1ddb3cc05ee770419f14409b2c2aaa8e789dfb8c Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 16:02:06 -0400 Subject: [PATCH 17/29] fix support email --- src/components/feed/SubscriptionDialog.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 39fbb2e0..fdaf22c0 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -22,7 +22,6 @@ import { TooltipContent, TooltipTrigger, } from "~/components/ui/tooltip"; -import { env } from "~/env"; import { authClient, useSession } from "~/lib/auth-client"; import { useFeeds } from "~/lib/data/feeds/store"; import { usePlanSuccessStore } from "~/lib/data/plan-success"; @@ -638,7 +637,7 @@ export function SubscriptionDialog({ Price too high or need higher limits?{" "} Date: Sun, 19 Apr 2026 18:15:28 -0400 Subject: [PATCH 18/29] code review changes, cleanup --- src/components/feed/SubscriptionDialog.tsx | 11 ++-- src/env.js | 4 +- src/server/api/routers/subscriptionRouter.ts | 29 +++++++-- src/server/auth/index.tsx | 68 ++++++-------------- src/server/subscriptions/helpers.ts | 41 ++++++++---- src/server/subscriptions/plans.ts | 43 +++++++++---- 6 files changed, 108 insertions(+), 88 deletions(-) diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index fdaf22c0..15ae3a61 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -282,9 +282,9 @@ function ProPlanCard({ isLoadingProducts: boolean; isSubscribed: boolean; onSubscribeClick: (id: "pro") => void; - portalMutation: any; - checkoutMutation: any; - previewMutation: any; + portalMutation: { isPending: boolean; mutate: (args: object) => void }; + checkoutMutation: { isPending: boolean }; + previewMutation: { isPending: boolean }; }) { const plan = PLANS.pro; const isCurrent = planId === "pro"; @@ -453,14 +453,15 @@ function PlanSwitchConfirmation({

- Due today (prorated): + Estimated charge today: {" "} - {formatPrice(preview.proratedAmount)} + ~{formatPrice(preview.proratedAmount)}

You'll be credited for the unused time on your current plan. + The final amount may differ slightly.

)} diff --git a/src/env.js b/src/env.js index b3c54a6d..42d79c46 100644 --- a/src/env.js +++ b/src/env.js @@ -8,7 +8,7 @@ export const env = createEnv({ * These are exposed to the browser via Vite's VITE_PUBLIC_ prefix. */ client: { - VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: z.string().optional(), + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: z.string().email().optional(), }, /** * Specify your server-side environment variables schema here. This way you can ensure the app @@ -26,7 +26,7 @@ export const env = createEnv({ BETTER_AUTH_SECRET: z.string(), RESEND_API_KEY: z.string().optional(), SENDGRID_API_KEY: z.string().optional(), - FROM_EMAIL_ADDRESS: z.string().optional(), + FROM_EMAIL_ADDRESS: z.string().email().optional(), INSTAPAPER_OAUTH_ID: z.string().optional(), INSTAPAPER_OAUTH_SECRET: z.string().optional(), POLAR_ACCESS_TOKEN: z.string().optional(), diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 0fe7b110..12c41ae1 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -9,6 +9,7 @@ import { import { IS_BILLING_ENABLED, polarClient } from "~/server/subscriptions/polar"; import { determinePlanFromProductId, + getAllKnownProductIds, PLANS, } from "~/server/subscriptions/plans"; import { @@ -51,7 +52,7 @@ type PlanProduct = { }; let productsCache: CachedProducts | null = null; -const CACHE_TTL_MS = 5 * 1000; // 5 seconds +const CACHE_TTL_MS = 30 * 1000; // 30 seconds export const getStatus = protectedProcedure.handler(async ({ context }) => { return getUserPlanLimits(context.db, context.user.id); @@ -213,16 +214,24 @@ export const createCheckout = protectedProcedure } const origin = getValidatedOrigin(context.headers); - // Ensure returnPath starts with / and doesn't contain query params that could break the URL - const safePath = input.returnPath?.startsWith("/") - ? input.returnPath.split("?")[0]! - : "/"; - const separator = safePath.includes("?") ? "&" : "?"; + // Validate returnPath: resolve against origin and verify it stays on the same host. + // This prevents open-redirect via protocol-relative paths (//evil.com) or traversal (/../). + let safePath = "/"; + if (input.returnPath) { + try { + const resolved = new URL(input.returnPath, origin); + if (resolved.origin === origin) { + safePath = resolved.pathname; + } + } catch { + // Malformed path, fall back to "/" + } + } const checkout = await polarClient.checkouts.create({ externalCustomerId: context.user.id, customerEmail: context.user.email, products: productIds, - successUrl: `${origin}${safePath}${separator}checkout_success=true`, + successUrl: `${origin}${safePath}?checkout_success=true`, returnUrl: `${origin}${safePath}`, }); @@ -316,6 +325,12 @@ export const switchPlan = protectedProcedure return { success: false }; } + // Verify the new product ID belongs to a known plan + const knownProductIds = getAllKnownProductIds(); + if (!knownProductIds.has(input.newProductId)) { + throw new Error("Invalid product ID"); + } + // Verify the subscription belongs to this user const subscriptions = await polarClient.subscriptions.list({ externalCustomerId: [context.user.id], diff --git a/src/server/auth/index.tsx b/src/server/auth/index.tsx index bb4a3412..efe63eb8 100644 --- a/src/server/auth/index.tsx +++ b/src/server/auth/index.tsx @@ -12,6 +12,7 @@ import { checkout, polar, portal, webhooks } from "@polar-sh/better-auth"; import { db } from "../db"; import { account, appConfig, session, user } from "../db/schema"; import { polarClient } from "../subscriptions/polar"; +import { PLANS } from "../subscriptions/plans"; import { applySubscriptionSideEffects, syncPolarDataToKV, @@ -84,56 +85,23 @@ function buildPolarPlugin() { if (!polarClient) return []; if (!process.env.POLAR_WEBHOOK_SECRET) return []; - const products = [ - process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID, - slug: "standard-small-monthly", - } - : null, - process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID, - slug: "standard-small-annual", - } - : null, - process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID, - slug: "standard-medium-monthly", - } - : null, - process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID, - slug: "standard-medium-annual", - } - : null, - process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID, - slug: "standard-large-monthly", - } - : null, - process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID - ? { - productId: process.env.POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID, - slug: "standard-large-annual", - } - : null, - process.env.POLAR_PRO_MONTHLY_PRODUCT_ID - ? { - productId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID, - slug: "pro-monthly", - } - : null, - process.env.POLAR_PRO_ANNUAL_PRODUCT_ID - ? { - productId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID, - slug: "pro-annual", - } - : null, - ].filter(Boolean); + // Build products list from plan config — each plan can have a monthly and/or annual product. + const products = Object.values(PLANS).flatMap((plan) => { + const entries: Array<{ productId: string; slug: string }> = []; + if (plan.polarMonthlyProductId) { + entries.push({ + productId: plan.polarMonthlyProductId, + slug: `${plan.id}-monthly`, + }); + } + if (plan.polarAnnualProductId) { + entries.push({ + productId: plan.polarAnnualProductId, + slug: `${plan.id}-annual`, + }); + } + return entries; + }); return [ polar({ diff --git a/src/server/subscriptions/helpers.ts b/src/server/subscriptions/helpers.ts index 74e5a42f..2f961b60 100644 --- a/src/server/subscriptions/helpers.ts +++ b/src/server/subscriptions/helpers.ts @@ -1,4 +1,4 @@ -import { and, asc, count, eq, inArray } from "drizzle-orm"; +import { and, asc, count, eq, inArray, sql } from "drizzle-orm"; import { getEffectivePlanConfig } from "./plans"; import { IS_BILLING_ENABLED } from "./polar"; import { getSubscriptionFromKV, syncPolarDataToKV } from "./kv"; @@ -77,7 +77,7 @@ export async function getUserPlanLimits(db: DB, userId: string) { /** * Check if the user is eligible to refresh based on their plan's refresh interval. - * Returns the next eligible fetch time if they are rate-limited, or null if they can refresh now. + * Uses an atomic compare-and-swap UPDATE to avoid TOCTOU races under concurrent requests. */ export async function checkUserRefreshEligibility( db: DB, @@ -86,23 +86,38 @@ export async function checkUserRefreshEligibility( const planId = await getUserPlanId(userId); const config = getEffectivePlanConfig(planId); + const now = new Date(); + const nowEpoch = Math.floor(now.getTime() / 1000); + const nextFetchAt = new Date(now.getTime() + config.refreshIntervalMs); + + // Atomic: only update nextFetchAt if the current value is null or in the past. + // If a concurrent request already claimed this window, rowsAffected will be 0. + const result = await db + .update(user) + .set({ nextFetchAt }) + .where( + and( + eq(user.id, userId), + sql`(${user.nextFetchAt} IS NULL OR ${user.nextFetchAt} <= ${nowEpoch})`, + ), + ); + + const rowsAffected = result.rowsAffected ?? 0; + if (rowsAffected > 0) { + return { eligible: true }; + } + + // Rate-limited — read back the current nextFetchAt to report to the user const userRow = await db .select({ nextFetchAt: user.nextFetchAt }) .from(user) .where(eq(user.id, userId)) .get(); - const now = new Date(); - - if (userRow?.nextFetchAt && userRow.nextFetchAt > now) { - return { eligible: false, nextFetchAt: userRow.nextFetchAt }; - } - - // Update user's nextFetchAt for the next refresh window - const nextFetchAt = new Date(now.getTime() + config.refreshIntervalMs); - await db.update(user).set({ nextFetchAt }).where(eq(user.id, userId)); - - return { eligible: true }; + return { + eligible: false, + nextFetchAt: userRow?.nextFetchAt ?? nextFetchAt, + }; } export async function deactivateExcessFeeds( diff --git a/src/server/subscriptions/plans.ts b/src/server/subscriptions/plans.ts index 6ec00003..02375d5c 100644 --- a/src/server/subscriptions/plans.ts +++ b/src/server/subscriptions/plans.ts @@ -20,12 +20,23 @@ export type PlanConfig = { polarAnnualProductId: string | null; }; +/** + * Small buffer subtracted from refresh intervals so users don't hit the + * rate-limit boundary when refreshing right on the dot (e.g. every 15 min). + */ +const REFRESH_PERIOD_BUFFER = 15_000; + +const STANDARD_REFRESH_MS = 15 * 60 * 1000 - REFRESH_PERIOD_BUFFER; // ~15 minutes +const STANDARD_BACKGROUND_REFRESH_MS = 4 * 60 * 60 * 1000; // 4 hours +const PRO_REFRESH_MS = 1 * 60 * 1000 - REFRESH_PERIOD_BUFFER; // ~1 minute +const PRO_BACKGROUND_REFRESH_MS = 1 * 60 * 1000; // 1 minute + export const PLANS: Record = { free: { id: "free", name: "Free", maxActiveFeeds: 40, - refreshIntervalMs: 60 * 60 * 1000 - 15_000, // 1 hour + refreshIntervalMs: 60 * 60 * 1000 - REFRESH_PERIOD_BUFFER, // ~1 hour backgroundRefreshIntervalMs: null, polarMonthlyProductId: null, polarAnnualProductId: null, @@ -34,8 +45,8 @@ export const PLANS: Record = { id: "standard-small", name: "Small", maxActiveFeeds: 200, - refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes - backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours + refreshIntervalMs: STANDARD_REFRESH_MS, + backgroundRefreshIntervalMs: STANDARD_BACKGROUND_REFRESH_MS, polarMonthlyProductId: process.env.POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: @@ -45,8 +56,8 @@ export const PLANS: Record = { id: "standard-medium", name: "Medium", maxActiveFeeds: 500, - refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes - backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours + refreshIntervalMs: STANDARD_REFRESH_MS, + backgroundRefreshIntervalMs: STANDARD_BACKGROUND_REFRESH_MS, polarMonthlyProductId: process.env.POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: @@ -56,8 +67,8 @@ export const PLANS: Record = { id: "standard-large", name: "Large", maxActiveFeeds: 1000, - refreshIntervalMs: 15 * 60 * 1000 - 15_000, // 15 minutes - backgroundRefreshIntervalMs: 4 * 60 * 60 * 1000, // 4 hours + refreshIntervalMs: STANDARD_REFRESH_MS, + backgroundRefreshIntervalMs: STANDARD_BACKGROUND_REFRESH_MS, polarMonthlyProductId: process.env.POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: @@ -67,8 +78,8 @@ export const PLANS: Record = { id: "pro", name: "Pro", maxActiveFeeds: 2500, - refreshIntervalMs: 1 * 60 * 1000 - 15_000, // 1 minute - backgroundRefreshIntervalMs: 1 * 60 * 1000, // 1 minute + refreshIntervalMs: PRO_REFRESH_MS, + backgroundRefreshIntervalMs: PRO_BACKGROUND_REFRESH_MS, polarMonthlyProductId: process.env.POLAR_PRO_MONTHLY_PRODUCT_ID ?? null, polarAnnualProductId: process.env.POLAR_PRO_ANNUAL_PRODUCT_ID ?? null, }, @@ -78,8 +89,8 @@ const UNLIMITED_CONFIG: PlanConfig = { id: "pro", name: "Pro", maxActiveFeeds: Infinity, - refreshIntervalMs: 1 * 60 * 1000 - 15_000, - backgroundRefreshIntervalMs: 1 * 60 * 1000, + refreshIntervalMs: PRO_REFRESH_MS, + backgroundRefreshIntervalMs: PRO_BACKGROUND_REFRESH_MS, polarMonthlyProductId: null, polarAnnualProductId: null, }; @@ -100,3 +111,13 @@ export function determinePlanFromProductId(productId: string): PlanId | null { } return null; } + +/** Returns the set of all configured Polar product IDs across every plan. */ +export function getAllKnownProductIds(): Set { + const ids = new Set(); + for (const plan of Object.values(PLANS)) { + if (plan.polarMonthlyProductId) ids.add(plan.polarMonthlyProductId); + if (plan.polarAnnualProductId) ids.add(plan.polarAnnualProductId); + } + return ids; +} From d9acf40f7d3cb9aee65c29293d09262a144305d7 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 22:47:25 -0400 Subject: [PATCH 19/29] track pending plan in ui --- src/components/feed/SubscriptionDialog.tsx | 325 ++++++++++++++++++- src/components/ui/card-radio-group.tsx | 9 +- src/server/api/routers/subscriptionRouter.ts | 148 ++++++++- 3 files changed, 450 insertions(+), 32 deletions(-) diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 15ae3a61..42691f03 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -13,7 +13,9 @@ import { import { useState } from "react"; import { toast } from "sonner"; import type { PlanConfig } from "~/server/subscriptions/plans"; +import type { CardRadioOption } from "~/components/ui/card-radio-group"; import { Button } from "~/components/ui/button"; +import { CardRadioGroup } from "~/components/ui/card-radio-group"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; import { Skeleton } from "~/components/ui/skeleton"; @@ -63,11 +65,26 @@ const RECOMMENDATION_MESSAGES = { "We think this plan is right for you, as it will allow you to keep all your feeds active.", } as const; +type BillingInterval = "month" | "year"; + +const INTERVAL_LABELS: Record = { + month: "mo", + year: "yr", +}; + function formatPrice(cents: number): string { const dollars = cents / 100; return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; } +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); +} + export function getPlanFeatures(plan: PlanConfig): string[] { const features: string[] = []; @@ -185,21 +202,105 @@ type SwitchPreview = { newPlanName: string; newAmount: number; proratedAmount: number; + isDowngrade: boolean; + periodEnd: string; currency: string; billingInterval: "month" | "year"; subscriptionId: string; newProductId: string; }; +type DowngradePreview = { + currentPlanId: string; + currentPlanName: string; + periodEnd: string; + subscriptionId: string; +}; + +function DowngradeConfirmation({ + preview, + onBack, + onConfirm, + isPending, +}: { + preview: DowngradePreview; + onBack: () => void; + onConfirm: () => void; + isPending: boolean; +}) { + const freePlan = PLANS.free; + const features = getPlanFeatures(freePlan); + const Icon = PLAN_ICONS.free; + + return ( + onBack()} + title="Downgrade Plan" + description={`Downgrade from ${preview.currentPlanName} to Free`} + onBack={onBack} + footer={ + + } + > +
+
+ +
+

{freePlan.name} Plan

+

Free

+
+
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+

+ Your plan will change on {formatDate(preview.periodEnd)} +

+

+ You'll keep your current {preview.currentPlanName} plan + features until the end of your billing period. After that, + you'll be switched to the Free plan automatically. +

+
+
+
+ ); +} + function FreePlanCard({ planId, recommendedPlanId, + chosenPlanId, + pendingDate, + hasAnyPending, + isSubscribed, + onDowngradeClick, + isDowngradeLoading, }: { planId: string; recommendedPlanId: string | null; + chosenPlanId: string; + pendingDate: string | null; + hasAnyPending: boolean; + isSubscribed: boolean; + onDowngradeClick: () => void; + isDowngradeLoading: boolean; }) { const plan = PLANS.free; const isCurrent = planId === "free"; + const isPending = pendingDate != null; const isRecommended = recommendedPlanId === "free"; const features = getPlanFeatures(plan); const Icon = PLAN_ICONS.free; @@ -216,13 +317,24 @@ function FreePlanCard({ : "border-border" }`} > - {(isCurrent || isRecommended) && ( + {(isCurrent || isRecommended || isPending) && (
{isCurrent && ( - + Current )} + {isPending && ( + + Starting {formatDate(pendingDate)} + + )} {isRecommended && ( @@ -232,7 +344,7 @@ function FreePlanCard({ - {isCurrent + {chosenPlanId === "free" ? RECOMMENDATION_MESSAGES.currentFree : RECOMMENDATION_MESSAGES.upgrade} @@ -255,6 +367,19 @@ function FreePlanCard({ ))} + {!isCurrent && isSubscribed && ( +
+ +
+ )}
); } @@ -262,6 +387,8 @@ function FreePlanCard({ function ProPlanCard({ planId, recommendedPlanId, + chosenPlanId, + hasAnyPending, products, isLoadingProducts, isSubscribed, @@ -272,6 +399,8 @@ function ProPlanCard({ }: { planId: string; recommendedPlanId: string | null; + chosenPlanId: string; + hasAnyPending: boolean; products: | Array<{ planId: string; @@ -311,7 +440,13 @@ function ProPlanCard({ {(isCurrent || isRecommended) && (
{isCurrent && ( - + Current )} @@ -324,7 +459,7 @@ function ProPlanCard({ - {isCurrent + {chosenPlanId === "pro" ? RECOMMENDATION_MESSAGES.currentPaid : RECOMMENDATION_MESSAGES.upgrade} @@ -402,16 +537,50 @@ function PlanSwitchConfirmation({ onBack, onConfirm, isPending, + onIntervalChange, + isLoadingPreview, + monthlyPrice, + annualPrice, }: { preview: SwitchPreview; onBack: () => void; onConfirm: () => void; isPending: boolean; + onIntervalChange: (interval: BillingInterval) => void; + isLoadingPreview: boolean; + monthlyPrice: number | null; + annualPrice: number | null; }) { + const [selectedInterval, setSelectedInterval] = useState( + preview.billingInterval, + ); + const newPlan = PLANS[preview.newPlanId as keyof typeof PLANS]; const features = getPlanFeatures(newPlan); const Icon = PLAN_ICONS[preview.newPlanId as keyof typeof PLAN_ICONS]; - const intervalLabel = preview.billingInterval === "month" ? "mo" : "yr"; + const intervalLabel = INTERVAL_LABELS[selectedInterval]; + + const hasBothIntervals = monthlyPrice != null && annualPrice != null; + + const intervalOptions: Array> = []; + if (monthlyPrice != null) { + intervalOptions.push({ + value: "month", + title: `Monthly — ${formatPrice(monthlyPrice)}/mo`, + }); + } + if (annualPrice != null) { + intervalOptions.push({ + value: "year", + title: `Annual — ${formatPrice(annualPrice)}/yr`, + description: `${formatPrice(Math.round(annualPrice / 12))}/mo`, + }); + } + + function handleIntervalChange(interval: BillingInterval) { + setSelectedInterval(interval); + onIntervalChange(interval); + } return ( +
+ {hasBothIntervals && ( + + )}
    {features.map((feature) => (
  • ))}
- {preview.proratedAmount > 0 && ( + {preview.isDowngrade ? ( +
+

+ Your plan will change on {formatDate(preview.periodEnd)} +

+

+ You'll keep your current {preview.currentPlanName} plan + features until the end of your billing period. After that, + you'll be switched to the {preview.newPlanName} plan + automatically. +

+
+ ) : preview.proratedAmount > 0 ? (

Estimated charge today: {" "} - ~{formatPrice(preview.proratedAmount)} + {formatPrice(preview.proratedAmount)}

You'll be credited for the unused time on your current plan. - The final amount may differ slightly. + The final amount may differ slightly based on your local tax + rates.

- )} + ) : null}
); @@ -498,6 +692,8 @@ export function SubscriptionDialog({ const [switchPreview, setSwitchPreview] = useState( null, ); + const [downgradePreview, setDowngradePreview] = + useState(null); const emailVerified = session?.user?.emailVerified ?? false; @@ -506,6 +702,11 @@ export function SubscriptionDialog({ enabled: open, }); + const { data: pendingSwitch } = useQuery({ + ...orpc.subscription.getPendingSwitch.queryOptions(), + enabled: open, + }); + const checkoutMutation = useMutation( orpc.subscription.createCheckout.mutationOptions({ onSuccess: (result) => { @@ -567,6 +768,42 @@ export function SubscriptionDialog({ }), ); + const downgradePreviewMutation = useMutation( + orpc.subscription.previewDowngrade.mutationOptions({ + onSuccess: (result) => { + if (result) { + setDowngradePreview(result); + } else { + toast.error("Unable to preview downgrade"); + } + }, + }), + ); + + const cancelMutation = useMutation( + orpc.subscription.cancelSubscription.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + setDowngradePreview(null); + onOpenChange(false); + toast.success( + "Your subscription will be cancelled at the end of your billing period.", + ); + void queryClient.invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getPendingSwitch.queryOptions().queryKey, + }); + } + }, + onError: () => { + toast.error("Failed to cancel subscription. Please try again."); + }, + }), + ); + const isSubscribed = planId !== "free"; const feeds = useFeeds(); @@ -577,6 +814,8 @@ export function SubscriptionDialog({ feeds.length, currentPlanIndex, ); + const hasAnyPending = pendingSwitch != null; + const chosenPlanId = pendingSwitch?.planId ?? planId; function handleSubscribeClick( id: "standard-small" | "standard-medium" | "standard-large" | "pro", @@ -608,7 +847,22 @@ export function SubscriptionDialog({ } } + if (downgradePreview) { + return ( + setDowngradePreview(null)} + onConfirm={() => cancelMutation.mutate({})} + isPending={cancelMutation.isPending} + /> + ); + } + if (switchPreview) { + const newPlanProduct = products?.find( + (p) => p.planId === switchPreview.newPlanId, + ); + return ( + previewMutation.mutate({ + planId: switchPreview.newPlanId as + | "standard-small" + | "standard-medium" + | "standard-large" + | "pro", + billingInterval: interval, + }) + } + monthlyPrice={newPlanProduct?.monthlyPrice ?? null} + annualPrice={newPlanProduct?.annualPrice ?? null} /> ); } @@ -667,7 +934,18 @@ export function SubscriptionDialog({ )} {/* Free plan */} - + downgradePreviewMutation.mutate({})} + isDowngradeLoading={downgradePreviewMutation.isPending} + /> {/* Paid plans */}
@@ -686,10 +964,12 @@ export function SubscriptionDialog({ ))} -
+
{STANDARD_PLAN_IDS.map((id) => { const plan = PLANS[id]; const isCurrent = id === planId; + const isPending = pendingSwitch?.planId === id; + const pendingDate = isPending ? pendingSwitch.appliesAt : null; const isRecommended = id === recommendedPlanId; const product = products?.find((p) => p.planId === id); const monthlyPrice = product?.monthlyPrice ?? null; @@ -710,13 +990,24 @@ export function SubscriptionDialog({ : "border-border" }`} > - {(isCurrent || isRecommended) && ( + {(isCurrent || isPending || isRecommended) && (
{isCurrent && ( - + Current )} + {pendingDate && ( + + Starting {formatDate(pendingDate)} + + )} {isRecommended && ( @@ -726,7 +1017,7 @@ export function SubscriptionDialog({ - {isCurrent + {chosenPlanId === id ? RECOMMENDATION_MESSAGES.currentPaid : RECOMMENDATION_MESSAGES.upgrade} @@ -797,6 +1088,8 @@ export function SubscriptionDialog({ ({ key={option.value} htmlFor={id} className={cn( - "bg-card text-card-foreground hover:bg-accent/30 flex cursor-pointer items-start gap-3 rounded-xl border p-4 shadow-sm transition-colors", + "bg-card text-card-foreground hover:bg-accent/30 flex cursor-pointer gap-3 rounded-xl border p-4 shadow-sm transition-colors", + option.description ? "items-start" : "items-center", isSelected && "border-primary ring-primary/30 ring-1", )} > - +
{option.title} diff --git a/src/server/api/routers/subscriptionRouter.ts b/src/server/api/routers/subscriptionRouter.ts index 12c41ae1..64b29495 100644 --- a/src/server/api/routers/subscriptionRouter.ts +++ b/src/server/api/routers/subscriptionRouter.ts @@ -10,6 +10,7 @@ import { IS_BILLING_ENABLED, polarClient } from "~/server/subscriptions/polar"; import { determinePlanFromProductId, getAllKnownProductIds, + PLAN_IDS, PLANS, } from "~/server/subscriptions/plans"; import { @@ -247,6 +248,7 @@ export const previewPlanSwitch = protectedProcedure "standard-large", "pro", ]), + billingInterval: z.enum(["month", "year"]).optional(), }), ) .handler(async ({ context, input }) => { @@ -267,7 +269,8 @@ export const previewPlanSwitch = protectedProcedure if (!currentPlanId || currentPlanId === input.planId) return null; const newPlan = PLANS[input.planId]; - const isMonthly = currentSub.recurringInterval === "month"; + const interval = input.billingInterval ?? currentSub.recurringInterval; + const isMonthly = interval === "month"; const newProductId = isMonthly ? newPlan.polarMonthlyProductId : newPlan.polarAnnualProductId; @@ -286,17 +289,25 @@ export const previewPlanSwitch = protectedProcedure return null; } - // Calculate proration - const now = Date.now(); - const periodStart = new Date(currentSub.currentPeriodStart).getTime(); - const periodEnd = new Date(currentSub.currentPeriodEnd).getTime(); - const totalPeriod = periodEnd - periodStart; - const elapsed = now - periodStart; - const remaining = Math.max(0, 1 - elapsed / totalPeriod); - - const currentCredit = Math.round(currentSub.amount * remaining); - const newCharge = Math.round((newAmount ?? 0) * remaining); - const proratedAmount = Math.max(0, newCharge - currentCredit); + // Determine upgrade vs downgrade by plan tier order + const currentPlanIndex = PLAN_IDS.indexOf(currentPlanId); + const newPlanIndex = PLAN_IDS.indexOf(input.planId); + const isDowngrade = newPlanIndex < currentPlanIndex; + + // Calculate proration (only meaningful for upgrades) + let proratedAmount = 0; + if (!isDowngrade) { + const now = Date.now(); + const periodStart = new Date(currentSub.currentPeriodStart).getTime(); + const periodEnd = new Date(currentSub.currentPeriodEnd).getTime(); + const totalPeriod = periodEnd - periodStart; + const elapsed = now - periodStart; + const remaining = Math.max(0, 1 - elapsed / totalPeriod); + + const currentCredit = Math.round(currentSub.amount * remaining); + const newCharge = Math.round((newAmount ?? 0) * remaining); + proratedAmount = Math.max(0, newCharge - currentCredit); + } return { currentPlanId, @@ -306,8 +317,10 @@ export const previewPlanSwitch = protectedProcedure newPlanName: newPlan.name, newAmount: newAmount ?? 0, proratedAmount, + isDowngrade, + periodEnd: new Date(currentSub.currentPeriodEnd).toISOString(), currency: currentSub.currency, - billingInterval: currentSub.recurringInterval as "month" | "year", + billingInterval: interval as "month" | "year", subscriptionId: currentSub.id, newProductId, }; @@ -344,11 +357,20 @@ export const switchPlan = protectedProcedure throw new Error("Subscription not found"); } + // Determine upgrade vs downgrade to choose proration strategy: + // - Upgrades: "invoice" — charge the prorated difference immediately + // - Downgrades: "next_period" — defer the switch to the next billing cycle + const currentPlanId = determinePlanFromProductId(sub.productId); + const newPlanId = determinePlanFromProductId(input.newProductId); + const currentIndex = currentPlanId ? PLAN_IDS.indexOf(currentPlanId) : -1; + const newIndex = newPlanId ? PLAN_IDS.indexOf(newPlanId) : -1; + const isDowngrade = newIndex < currentIndex; + await polarClient.subscriptions.update({ id: input.subscriptionId, subscriptionUpdate: { productId: input.newProductId, - prorationBehavior: "prorate", + prorationBehavior: isDowngrade ? "next_period" : "invoice", }, }); @@ -393,6 +415,104 @@ export const syncAfterCheckout = protectedProcedure.handler( }, ); +export const getPendingSwitch = protectedProcedure.handler( + async ({ context }) => { + if (!IS_BILLING_ENABLED || !polarClient) return null; + + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [context.user.id], + active: true, + }); + + const sub = subscriptions.result?.items?.[0]; + if (!sub) return null; + + // Subscription set to cancel at period end → user reverts to free + if (sub.cancelAtPeriodEnd && sub.currentPeriodEnd) { + return { + planId: "free" as const, + appliesAt: new Date(sub.currentPeriodEnd).toISOString(), + }; + } + + if (!sub.pendingUpdate?.productId) return null; + + const pendingPlanId = determinePlanFromProductId( + sub.pendingUpdate.productId, + ); + if (!pendingPlanId) return null; + + return { + planId: pendingPlanId, + appliesAt: new Date(sub.pendingUpdate.appliesAt).toISOString(), + }; + }, +); + +export const previewDowngrade = protectedProcedure.handler( + async ({ context }) => { + if (!IS_BILLING_ENABLED || !polarClient) return null; + + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [context.user.id], + active: true, + }); + + const sub = subscriptions.result?.items?.[0]; + if (!sub) return null; + + const currentPlanId = determinePlanFromProductId(sub.productId); + if (!currentPlanId || currentPlanId === "free") return null; + + return { + currentPlanId, + currentPlanName: PLANS[currentPlanId].name, + periodEnd: new Date(sub.currentPeriodEnd).toISOString(), + subscriptionId: sub.id, + }; + }, +); + +export const cancelSubscription = protectedProcedure.handler( + async ({ context }) => { + if (!IS_BILLING_ENABLED || !polarClient) { + return { success: false }; + } + + const subscriptions = await polarClient.subscriptions.list({ + externalCustomerId: [context.user.id], + active: true, + }); + + const sub = subscriptions.result?.items?.[0]; + if (!sub) { + throw new Error("No active subscription found"); + } + + await polarClient.subscriptions.update({ + id: sub.id, + subscriptionUpdate: { + cancelAtPeriodEnd: true, + }, + }); + + console.log( + `[polar] Subscription cancelled at period end for user=${context.user.id} subscription=${sub.id}`, + ); + + try { + await syncPolarDataToKV(context.user.id); + } catch (e) { + console.warn( + `[polar] Post-cancel sync failed for user=${context.user.id}:`, + e, + ); + } + + return { success: true }; + }, +); + export const createPortalSession = protectedProcedure.handler( async ({ context }) => { if (!IS_BILLING_ENABLED || !polarClient) { From 33ed22eba8a47f6a2d16999f328cb6f570ad5aee Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Sun, 19 Apr 2026 23:13:54 -0400 Subject: [PATCH 20/29] combine switch dialogs into main subscription dialog --- src/components/feed/SubscriptionDialog.tsx | 869 +++++++++--------- .../feed/SubscriptionDialogContext.tsx | 90 ++ 2 files changed, 534 insertions(+), 425 deletions(-) create mode 100644 src/components/feed/SubscriptionDialogContext.tsx diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx index 42691f03..f5860a46 100644 --- a/src/components/feed/SubscriptionDialog.tsx +++ b/src/components/feed/SubscriptionDialog.tsx @@ -10,7 +10,7 @@ import { TreePineIcon, TreesIcon, } from "lucide-react"; -import { useState } from "react"; +import { createContext, useContext, useState } from "react"; import { toast } from "sonner"; import type { PlanConfig } from "~/server/subscriptions/plans"; import type { CardRadioOption } from "~/components/ui/card-radio-group"; @@ -194,7 +194,7 @@ function EmailVerificationBanner({ onVerified }: { onVerified: () => void }) { ); } -type SwitchPreview = { +export type SwitchPreview = { currentPlanId: string; currentPlanName: string; currentAmount: number; @@ -210,96 +210,72 @@ type SwitchPreview = { newProductId: string; }; -type DowngradePreview = { +export type DowngradePreview = { currentPlanId: string; currentPlanName: string; periodEnd: string; subscriptionId: string; }; -function DowngradeConfirmation({ +function DowngradeConfirmationContent({ preview, - onBack, - onConfirm, - isPending, }: { preview: DowngradePreview; - onBack: () => void; - onConfirm: () => void; - isPending: boolean; }) { const freePlan = PLANS.free; const features = getPlanFeatures(freePlan); const Icon = PLAN_ICONS.free; return ( - onBack()} - title="Downgrade Plan" - description={`Downgrade from ${preview.currentPlanName} to Free`} - onBack={onBack} - footer={ - - } - > -
-
- -
-

{freePlan.name} Plan

-

Free

-
-
-
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
-

- Your plan will change on {formatDate(preview.periodEnd)} -

-

- You'll keep your current {preview.currentPlanName} plan - features until the end of your billing period. After that, - you'll be switched to the Free plan automatically. -

+
+
+ +
+

{freePlan.name} Plan

+

Free

- +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+

+ Your plan will change on {formatDate(preview.periodEnd)} +

+

+ You'll keep your current {preview.currentPlanName} plan features + until the end of your billing period. After that, you'll be + switched to the Free plan automatically. +

+
+
); } -function FreePlanCard({ - planId, - recommendedPlanId, - chosenPlanId, - pendingDate, - hasAnyPending, - isSubscribed, - onDowngradeClick, - isDowngradeLoading, -}: { - planId: string; - recommendedPlanId: string | null; - chosenPlanId: string; - pendingDate: string | null; - hasAnyPending: boolean; - isSubscribed: boolean; - onDowngradeClick: () => void; - isDowngradeLoading: boolean; -}) { +function FreePlanCard() { + const { + planId, + recommendedPlanId, + chosenPlanId, + pendingSwitch, + hasAnyPending, + isSubscribed, + onDowngradeClick, + isDowngradeLoading, + } = useSubscriptionDialogContext(); + const plan = PLANS.free; const isCurrent = planId === "free"; + const pendingDate = + pendingSwitch?.planId === "free" ? pendingSwitch.appliesAt : null; const isPending = pendingDate != null; const isRecommended = recommendedPlanId === "free"; const features = getPlanFeatures(plan); @@ -384,37 +360,20 @@ function FreePlanCard({ ); } -function ProPlanCard({ - planId, - recommendedPlanId, - chosenPlanId, - hasAnyPending, - products, - isLoadingProducts, - isSubscribed, - onSubscribeClick, - portalMutation, - checkoutMutation, - previewMutation, -}: { - planId: string; - recommendedPlanId: string | null; - chosenPlanId: string; - hasAnyPending: boolean; - products: - | Array<{ - planId: string; - monthlyPrice: number | null; - annualPrice: number | null; - }> - | undefined; - isLoadingProducts: boolean; - isSubscribed: boolean; - onSubscribeClick: (id: "pro") => void; - portalMutation: { isPending: boolean; mutate: (args: object) => void }; - checkoutMutation: { isPending: boolean }; - previewMutation: { isPending: boolean }; -}) { +function ProPlanCard() { + const { + planId, + recommendedPlanId, + chosenPlanId, + hasAnyPending, + products, + isLoadingProducts, + isSubscribed, + onSubscribeClick, + portalMutation, + checkoutMutation, + previewMutation, + } = useSubscriptionDialogContext(); const plan = PLANS.pro; const isCurrent = planId === "pro"; const isRecommended = recommendedPlanId === "pro"; @@ -532,22 +491,14 @@ function ProPlanCard({ ); } -function PlanSwitchConfirmation({ +function PlanSwitchConfirmationContent({ preview, - onBack, - onConfirm, - isPending, onIntervalChange, - isLoadingPreview, monthlyPrice, annualPrice, }: { preview: SwitchPreview; - onBack: () => void; - onConfirm: () => void; - isPending: boolean; onIntervalChange: (interval: BillingInterval) => void; - isLoadingPreview: boolean; monthlyPrice: number | null; annualPrice: number | null; }) { @@ -583,84 +534,64 @@ function PlanSwitchConfirmation({ } return ( - onBack()} - title="Switch Plan" - description={`Switch from ${preview.currentPlanName} to ${preview.newPlanName}`} - onBack={onBack} - footer={ - - } - > -
-
- -
-

{preview.newPlanName} Plan

-

- {formatPrice(preview.newAmount)}/{intervalLabel} -

-
+
+
+ +
+

{preview.newPlanName} Plan

+

+ {formatPrice(preview.newAmount)}/{intervalLabel} +

- {hasBothIntervals && ( - - )} -
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- {preview.isDowngrade ? ( -
-

- Your plan will change on {formatDate(preview.periodEnd)} -

-

- You'll keep your current {preview.currentPlanName} plan - features until the end of your billing period. After that, - you'll be switched to the {preview.newPlanName} plan - automatically. -

-
- ) : preview.proratedAmount > 0 ? ( -
-

- - Estimated charge today: - {" "} - - {formatPrice(preview.proratedAmount)} - -

-

- You'll be credited for the unused time on your current plan. - The final amount may differ slightly based on your local tax - rates. -

-
- ) : null}
- + {hasBothIntervals && ( + + )} +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {preview.isDowngrade ? ( +
+

+ Your plan will change on {formatDate(preview.periodEnd)} +

+

+ You'll keep your current {preview.currentPlanName} plan + features until the end of your billing period. After that, + you'll be switched to the {preview.newPlanName} plan + automatically. +

+
+ ) : preview.proratedAmount > 0 ? ( +
+

+ + Estimated charge today: + {" "} + + {formatPrice(preview.proratedAmount)} + +

+

+ You'll be credited for the unused time on your current plan. + The final amount may differ slightly based on your local tax rates. +

+
+ ) : null} +
); } @@ -675,6 +606,194 @@ function getRecommendedPlanId( return bestFit; } +type SubscriptionDialogContextValue = { + planId: string; + recommendedPlanId: string | null; + chosenPlanId: string; + hasAnyPending: boolean; + isSubscribed: boolean; + products: + | Array<{ + planId: string; + monthlyPrice: number | null; + annualPrice: number | null; + }> + | undefined; + isLoadingProducts: boolean; + pendingSwitch: { planId: string; appliesAt: string } | null | undefined; + onSubscribeClick: ( + id: "standard-small" | "standard-medium" | "standard-large" | "pro", + ) => void; + onDowngradeClick: () => void; + isDowngradeLoading: boolean; + portalMutation: { isPending: boolean; mutate: (args: object) => void }; + checkoutMutation: { isPending: boolean }; + previewMutation: { isPending: boolean }; +}; + +const SubscriptionDialogContext = + createContext(null); + +function useSubscriptionDialogContext() { + const ctx = useContext(SubscriptionDialogContext); + if (!ctx) { + throw new Error( + "useSubscriptionDialogContext must be used within SubscriptionDialogProvider", + ); + } + return ctx; +} + +function StandardPlanCards() { + const { + planId, + recommendedPlanId, + chosenPlanId, + hasAnyPending, + isSubscribed, + products, + isLoadingProducts, + pendingSwitch, + onSubscribeClick, + portalMutation, + checkoutMutation, + previewMutation, + } = useSubscriptionDialogContext(); + + return ( +
+
+ +

Standard

+
+
    + {STANDARD_FEATURES.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {STANDARD_PLAN_IDS.map((id) => { + const plan = PLANS[id]; + const isCurrent = id === planId; + const isPending = pendingSwitch?.planId === id; + const pendingDate = isPending ? pendingSwitch.appliesAt : null; + const isRecommended = id === recommendedPlanId; + const product = products?.find((p) => p.planId === id); + const monthlyPrice = product?.monthlyPrice ?? null; + const annualPrice = product?.annualPrice ?? null; + const hasPrice = monthlyPrice != null || annualPrice != null; + const feedsLabel = `Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`; + + return ( +
+ {(isCurrent || isPending || isRecommended) && ( +
+ {isCurrent && ( + + Current + + )} + {pendingDate && ( + + Starting {formatDate(pendingDate)} + + )} + {isRecommended && ( + + + + Recommended + + + + + {chosenPlanId === id + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+ {QUOTA_DISPLAY_NAMES[id]} +
+ {isLoadingProducts ? ( + + ) : hasPrice ? ( + + {monthlyPrice != null && + `${formatPrice(monthlyPrice)}/mo`} + {monthlyPrice != null && annualPrice != null && " · "} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))} + /mo + + + )} + + ) : null} + {isCurrent && isSubscribed ? ( + + ) : !isCurrent ? ( + + ) : null} +
+
+

{feedsLabel}

+
+ ); + })} +
+
+ ); +} + export function SubscriptionDialog({ open, onOpenChange, @@ -847,258 +966,158 @@ export function SubscriptionDialog({ } } - if (downgradePreview) { - return ( - setDowngradePreview(null)} - onConfirm={() => cancelMutation.mutate({})} - isPending={cancelMutation.isPending} - /> - ); - } + const contextValue: SubscriptionDialogContextValue = { + planId, + recommendedPlanId, + chosenPlanId, + hasAnyPending, + isSubscribed, + products, + isLoadingProducts, + pendingSwitch, + onSubscribeClick: handleSubscribeClick, + onDowngradeClick: () => downgradePreviewMutation.mutate({}), + isDowngradeLoading: downgradePreviewMutation.isPending, + portalMutation, + checkoutMutation, + previewMutation, + }; - if (switchPreview) { - const newPlanProduct = products?.find( - (p) => p.planId === switchPreview.newPlanId, - ); + const isSubView = downgradePreview != null || switchPreview != null; - return ( - setSwitchPreview(null)} - onConfirm={() => + let dialogTitle: string; + let dialogDescription: string; + let dialogFooter: React.ReactNode; + let dialogOnBack: (() => void) | undefined; + + if (downgradePreview) { + dialogTitle = "Downgrade Plan"; + dialogDescription = `Downgrade from ${downgradePreview.currentPlanName} to Free`; + dialogOnBack = () => setDowngradePreview(null); + dialogFooter = ( + + ); + } else if (switchPreview) { + dialogTitle = "Switch Plan"; + dialogDescription = `Switch from ${switchPreview.currentPlanName} to ${switchPreview.newPlanName}`; + dialogOnBack = () => setSwitchPreview(null); + dialogFooter = ( + + ); + } else { + dialogTitle = "Subscribe to Serial"; + dialogDescription = "All prices are taxes-included."; + dialogFooter = ( +

+ Price too high or need higher limits?{" "} + + + Let us know + {" "} + or{" "} + + learn how to self-host + {" "} + Serial + +

); } + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen && isSubView) { + setDowngradePreview(null); + setSwitchPreview(null); + return; + } + onOpenChange(nextOpen); + } + + const newPlanProduct = switchPreview + ? products?.find((p) => p.planId === switchPreview.newPlanId) + : undefined; + return ( - - Price too high or need higher limits?{" "} - - - Let us know - {" "} - or{" "} - - learn how to self-host - {" "} - Serial - -

- } - > -
- {showVerification && !emailVerified && ( -
- -
- )} + + + {downgradePreview ? ( + + ) : switchPreview ? ( + + previewMutation.mutate({ + planId: switchPreview.newPlanId as + | "standard-small" + | "standard-medium" + | "standard-large" + | "pro", + billingInterval: interval, + }) + } + monthlyPrice={newPlanProduct?.monthlyPrice ?? null} + annualPrice={newPlanProduct?.annualPrice ?? null} + /> + ) : ( +
+ {showVerification && !emailVerified && ( +
+ +
+ )} - {/* Free plan */} - downgradePreviewMutation.mutate({})} - isDowngradeLoading={downgradePreviewMutation.isPending} - /> + {/* Free plan */} + - {/* Paid plans */} -
-
- -

Standard

-
-
    - {STANDARD_FEATURES.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
- {STANDARD_PLAN_IDS.map((id) => { - const plan = PLANS[id]; - const isCurrent = id === planId; - const isPending = pendingSwitch?.planId === id; - const pendingDate = isPending ? pendingSwitch.appliesAt : null; - const isRecommended = id === recommendedPlanId; - const product = products?.find((p) => p.planId === id); - const monthlyPrice = product?.monthlyPrice ?? null; - const annualPrice = product?.annualPrice ?? null; - const hasPrice = monthlyPrice != null || annualPrice != null; - const feedsLabel = `Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`; - - return ( -
- {(isCurrent || isPending || isRecommended) && ( -
- {isCurrent && ( - - Current - - )} - {pendingDate && ( - - Starting {formatDate(pendingDate)} - - )} - {isRecommended && ( - - - - Recommended - - - - - {chosenPlanId === id - ? RECOMMENDATION_MESSAGES.currentPaid - : RECOMMENDATION_MESSAGES.upgrade} - - - )} -
- )} -
- - {QUOTA_DISPLAY_NAMES[id]} - -
- {isLoadingProducts ? ( - - ) : hasPrice ? ( - - {monthlyPrice != null && - `${formatPrice(monthlyPrice)}/mo`} - {monthlyPrice != null && annualPrice != null && " · "} - {annualPrice != null && ( - - - - {formatPrice(annualPrice)}/yr - - - - {formatPrice(Math.round(annualPrice / 12))} - /mo - - - )} - - ) : null} - {isCurrent && isSubscribed ? ( - - ) : !isCurrent ? ( - - ) : null} -
-
-

- {feedsLabel} -

-
- ); - })} -
-
+ {/* Paid plans */} + - {/* Pro plan */} - -
-
+ {/* Pro plan */} + +
+ )} +
+ ); } diff --git a/src/components/feed/SubscriptionDialogContext.tsx b/src/components/feed/SubscriptionDialogContext.tsx new file mode 100644 index 00000000..1eaf334e --- /dev/null +++ b/src/components/feed/SubscriptionDialogContext.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { UseMutationResult } from "@tanstack/react-query"; +import type { DowngradePreview, SwitchPreview } from "./SubscriptionDialog"; + +type MutationResult = UseMutationResult; + +interface SubscriptionDialogContextValue { + // Plan state + planId: string; + recommendedPlanId: string | null; + chosenPlanId: string; + hasAnyPending: boolean; + isSubscribed: boolean; + emailVerified: boolean; + showVerification: boolean; + + // Products + products: + | Array<{ + planId: string; + monthlyPrice: number | null; + annualPrice: number | null; + }> + | undefined; + isLoadingProducts: boolean; + + // Preview states + switchPreview: SwitchPreview | null; + downgradePreview: DowngradePreview | null; + pendingSwitch: { planId: string; appliesAt: string } | null; + pendingPlanId: + | "standard-small" + | "standard-medium" + | "standard-large" + | "pro" + | null; + + // Mutations + checkoutMutation: MutationResult<{ error?: string; url?: string }>; + previewMutation: MutationResult; + switchMutation: MutationResult<{ success: boolean }>; + portalMutation: MutationResult<{ url?: string }>; + downgradePreviewMutation: MutationResult; + cancelMutation: MutationResult<{ success: boolean }>; + + // Actions + setShowVerification: (show: boolean) => void; + setSwitchPreview: (preview: SwitchPreview | null) => void; + setDowngradePreview: (preview: DowngradePreview | null) => void; + setPendingPlanId: ( + id: "standard-small" | "standard-medium" | "standard-large" | "pro" | null, + ) => void; + + // Handlers + handleSubscribeClick: ( + id: "standard-small" | "standard-medium" | "standard-large" | "pro", + ) => void; + handleVerified: () => Promise; + handleDowngradeClick: () => void; +} + +const SubscriptionDialogContext = createContext< + SubscriptionDialogContextValue | undefined +>(undefined); + +export function SubscriptionDialogProvider({ + children, + value, +}: { + children: React.ReactNode; + value: SubscriptionDialogContextValue; +}) { + return ( + + {children} + + ); +} + +export function useSubscriptionDialog() { + const context = useContext(SubscriptionDialogContext); + if (!context) { + throw new Error( + "useSubscriptionDialog must be used within SubscriptionDialogProvider", + ); + } + return context; +} From 81ca81188918b6c03c9982945325d8c8625ef742 Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Mon, 20 Apr 2026 00:20:50 -0400 Subject: [PATCH 21/29] extensive subscription dialog qa --- src/app/_app.feeds.tsx | 7 +- src/app/_app.import.tsx | 3 +- src/app/_app.tsx | 24 + src/components/feed/SubscriptionDialog.tsx | 490 +++++++++++++----- .../feed/SubscriptionDialogContext.tsx | 14 +- src/components/feed/dialogStore.ts | 16 +- src/components/ui/card-radio-group.tsx | 12 +- src/components/ui/responsive-dropdown.tsx | 2 +- src/lib/data/feeds/mutations.ts | 4 +- src/server/api/routers/subscriptionRouter.ts | 122 ++++- 10 files changed, 538 insertions(+), 156 deletions(-) diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index a32e547c..946c5cdd 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -374,7 +374,8 @@ function ManageFeedsPage() { { action: { label: "Upgrade", - onClick: () => launchDialog("subscription"), + onClick: () => + launchDialog("subscription", { subscriptionView: "picker" }), }, }, ); @@ -489,7 +490,9 @@ function ManageFeedsPage() { will receive new content. +
+
    + {pendingFeatures.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + )}
-
-

{freePlan.name} Plan

-

Free

+
+

+ {hasPendingSwitch + ? `${summary.planName} Plan (Current)` + : `${summary.planName} Plan`} +

+

+ {hasPrice && `${formatPrice(summary.amount!)}/${intervalLabel}`} + {hasPrice && hasRenewalDate && " · "} + {hasRenewalDate && + (hasPendingSwitch + ? `Until ${formatDate(summary.currentPeriodEnd!)}` + : `Renews ${formatDate(summary.currentPeriodEnd!)}`)} +

+
-
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
-
-

- Your plan will change on {formatDate(preview.periodEnd)} -

-

- You'll keep your current {preview.currentPlanName} plan features - until the end of your billing period. After that, you'll be - switched to the Free plan automatically. -

-
+ {!hasPendingSwitch && ( +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ )}
); } @@ -266,10 +354,9 @@ function FreePlanCard() { recommendedPlanId, chosenPlanId, pendingSwitch, - hasAnyPending, isSubscribed, - onDowngradeClick, - isDowngradeLoading, + onSwitchToFreeClick, + isSwitchToFreeLoading, } = useSubscriptionDialogContext(); const plan = PLANS.free; @@ -296,18 +383,12 @@ function FreePlanCard() { {(isCurrent || isRecommended || isPending) && (
{isCurrent && ( - + Current )} {isPending && ( - + Starting {formatDate(pendingDate)} )} @@ -346,13 +427,12 @@ function FreePlanCard() { {!isCurrent && isSubscribed && (
)} @@ -365,12 +445,12 @@ function ProPlanCard() { planId, recommendedPlanId, chosenPlanId, - hasAnyPending, products, isLoadingProducts, isSubscribed, + currentBillingInterval, + onBillingCycleSwitch, onSubscribeClick, - portalMutation, checkoutMutation, previewMutation, } = useSubscriptionDialogContext(); @@ -384,6 +464,8 @@ function ProPlanCard() { const annualPrice = product?.annualPrice ?? null; const hasPrice = monthlyPrice != null || annualPrice != null; + const showBillingCycleSwitch = isCurrent && currentBillingInterval != null; + return (
{isCurrent && ( - + Current )} @@ -463,19 +539,16 @@ function ProPlanCard() { ))} - {isCurrent && isSubscribed ? ( + {showBillingCycleSwitch && (
- +
- ) : !isCurrent ? ( + )} + {!isCurrent && (
- ) : null} + )}
); } @@ -496,11 +569,13 @@ function PlanSwitchConfirmationContent({ onIntervalChange, monthlyPrice, annualPrice, + currentBillingInterval, }: { preview: SwitchPreview; onIntervalChange: (interval: BillingInterval) => void; monthlyPrice: number | null; annualPrice: number | null; + currentBillingInterval: BillingInterval | null; }) { const [selectedInterval, setSelectedInterval] = useState( preview.billingInterval, @@ -509,15 +584,19 @@ function PlanSwitchConfirmationContent({ const newPlan = PLANS[preview.newPlanId as keyof typeof PLANS]; const features = getPlanFeatures(newPlan); const Icon = PLAN_ICONS[preview.newPlanId as keyof typeof PLAN_ICONS]; - const intervalLabel = INTERVAL_LABELS[selectedInterval]; + const isFreePlan = preview.newPlanId === "free"; + const intervalLabel = isFreePlan ? null : INTERVAL_LABELS[selectedInterval]; - const hasBothIntervals = monthlyPrice != null && annualPrice != null; + const isSamePlanSwitch = preview.currentPlanId === preview.newPlanId; + const hasBothIntervals = + !isFreePlan && monthlyPrice != null && annualPrice != null; const intervalOptions: Array> = []; if (monthlyPrice != null) { intervalOptions.push({ value: "month", title: `Monthly — ${formatPrice(monthlyPrice)}/mo`, + disabled: isSamePlanSwitch && currentBillingInterval === "month", }); } if (annualPrice != null) { @@ -525,6 +604,7 @@ function PlanSwitchConfirmationContent({ value: "year", title: `Annual — ${formatPrice(annualPrice)}/yr`, description: `${formatPrice(Math.round(annualPrice / 12))}/mo`, + disabled: isSamePlanSwitch && currentBillingInterval === "year", }); } @@ -540,7 +620,9 @@ function PlanSwitchConfirmationContent({

{preview.newPlanName} Plan

- {formatPrice(preview.newAmount)}/{intervalLabel} + {isFreePlan + ? "Free" + : `${formatPrice(preview.newAmount)}/${intervalLabel}`}

@@ -610,7 +692,7 @@ type SubscriptionDialogContextValue = { planId: string; recommendedPlanId: string | null; chosenPlanId: string; - hasAnyPending: boolean; + isSubscribed: boolean; products: | Array<{ @@ -620,12 +702,21 @@ type SubscriptionDialogContextValue = { }> | undefined; isLoadingProducts: boolean; - pendingSwitch: { planId: string; appliesAt: string } | null | undefined; + pendingSwitch: + | { + planId: string; + billingInterval: "month" | "year" | null; + appliesAt: string; + } + | null + | undefined; onSubscribeClick: ( id: "standard-small" | "standard-medium" | "standard-large" | "pro", ) => void; - onDowngradeClick: () => void; - isDowngradeLoading: boolean; + onSwitchToFreeClick: () => void; + isSwitchToFreeLoading: boolean; + currentBillingInterval: BillingInterval | null; + onBillingCycleSwitch: (interval: BillingInterval) => void; portalMutation: { isPending: boolean; mutate: (args: object) => void }; checkoutMutation: { isPending: boolean }; previewMutation: { isPending: boolean }; @@ -644,18 +735,41 @@ function useSubscriptionDialogContext() { return ctx; } +function BillingCycleSwitchButton({ + currentInterval, + onSwitch, + isPending, +}: { + currentInterval: BillingInterval; + onSwitch: (interval: BillingInterval) => void; + isPending: boolean; +}) { + const alternateInterval: BillingInterval = + currentInterval === "month" ? "year" : "month"; + + return ( + + ); +} + function StandardPlanCards() { const { planId, recommendedPlanId, chosenPlanId, - hasAnyPending, isSubscribed, products, isLoadingProducts, pendingSwitch, + currentBillingInterval, + onBillingCycleSwitch, onSubscribeClick, - portalMutation, checkoutMutation, previewMutation, } = useSubscriptionDialogContext(); @@ -706,18 +820,12 @@ function StandardPlanCards() { {(isCurrent || isPending || isRecommended) && (
{isCurrent && ( - + Current )} {pendingDate && ( - + Starting {formatDate(pendingDate)} )} @@ -744,7 +852,7 @@ function StandardPlanCards() { {isLoadingProducts ? ( ) : hasPrice ? ( - + {monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} {monthlyPrice != null && annualPrice != null && " · "} @@ -763,16 +871,14 @@ function StandardPlanCards() { )} ) : null} - {isCurrent && isSubscribed ? ( - - ) : !isCurrent ? ( + {isCurrent && currentBillingInterval && ( + + )} + {!isCurrent && ( - ) : null} + )}

{feedsLabel}

@@ -811,10 +917,22 @@ export function SubscriptionDialog({ const [switchPreview, setSwitchPreview] = useState( null, ); - const [downgradePreview, setDowngradePreview] = - useState(null); + const [showPlanPicker, setShowPlanPicker] = useState(false); + const subscriptionView = useDialogStore((s) => s.subscriptionView); + const prevOpenRef = useRef(false); + + // Reset dialog state when it opens, respecting the requested view + useEffect(() => { + if (open && !prevOpenRef.current) { + setSwitchPreview(null); + setShowVerification(false); + setShowPlanPicker(subscriptionView === "picker"); + } + prevOpenRef.current = open; + }, [open, subscriptionView]); const emailVerified = session?.user?.emailVerified ?? false; + const isSubscribed = planId !== "free"; const { data: products, isLoading: isLoadingProducts } = useQuery({ ...orpc.subscription.getProducts.queryOptions(), @@ -826,6 +944,11 @@ export function SubscriptionDialog({ enabled: open, }); + const { data: subscriptionSummary, isLoading: isLoadingSummary } = useQuery({ + ...orpc.subscription.getSubscriptionSummary.queryOptions(), + enabled: open && isSubscribed, + }); + const checkoutMutation = useMutation( orpc.subscription.createCheckout.mutationOptions({ onSuccess: (result) => { @@ -887,13 +1010,51 @@ export function SubscriptionDialog({ }), ); + const revertPendingMutation = useMutation( + orpc.subscription.revertPendingChange.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + toast.success("Pending change cancelled."); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getPendingSwitch.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getSubscriptionSummary.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }); + } + }, + onError: () => { + toast.error("Failed to cancel pending change. Please try again."); + }, + }), + ); + const downgradePreviewMutation = useMutation( orpc.subscription.previewDowngrade.mutationOptions({ onSuccess: (result) => { if (result) { - setDowngradePreview(result); + setSwitchPreview({ + currentPlanId: result.currentPlanId, + currentPlanName: result.currentPlanName, + currentAmount: 0, + newPlanId: "free", + newPlanName: PLANS.free.name, + newAmount: 0, + proratedAmount: 0, + isDowngrade: true, + periodEnd: result.periodEnd, + currency: "usd", + billingInterval: "month", + subscriptionId: result.subscriptionId, + newProductId: "", + }); } else { - toast.error("Unable to preview downgrade"); + toast.error("Unable to preview switch"); } }, }), @@ -903,10 +1064,10 @@ export function SubscriptionDialog({ orpc.subscription.cancelSubscription.mutationOptions({ onSuccess: (result) => { if (result.success) { - setDowngradePreview(null); + setSwitchPreview(null); onOpenChange(false); toast.success( - "Your subscription will be cancelled at the end of your billing period.", + "Your plan will switch at the end of your billing period.", ); void queryClient.invalidateQueries({ queryKey: orpc.subscription.getStatus.queryOptions().queryKey, @@ -923,7 +1084,6 @@ export function SubscriptionDialog({ }), ); - const isSubscribed = planId !== "free"; const feeds = useFeeds(); // Recommend the smallest plan that fits the user's feed count, @@ -933,7 +1093,6 @@ export function SubscriptionDialog({ feeds.length, currentPlanIndex, ); - const hasAnyPending = pendingSwitch != null; const chosenPlanId = pendingSwitch?.planId ?? planId; function handleSubscribeClick( @@ -970,46 +1129,62 @@ export function SubscriptionDialog({ planId, recommendedPlanId, chosenPlanId, - hasAnyPending, isSubscribed, products, isLoadingProducts, pendingSwitch, onSubscribeClick: handleSubscribeClick, - onDowngradeClick: () => downgradePreviewMutation.mutate({}), - isDowngradeLoading: downgradePreviewMutation.isPending, + onSwitchToFreeClick: () => downgradePreviewMutation.mutate({}), + isSwitchToFreeLoading: downgradePreviewMutation.isPending, + currentBillingInterval: + (subscriptionSummary?.billingInterval as BillingInterval | null) ?? null, + onBillingCycleSwitch: (interval: BillingInterval) => { + if (!subscriptionSummary) return; + const paidPlanId = subscriptionSummary.planId; + previewMutation.mutate({ planId: paidPlanId, billingInterval: interval }); + }, portalMutation, checkoutMutation, previewMutation, }; - const isSubView = downgradePreview != null || switchPreview != null; + // Determine current view + const showOverview = + isSubscribed && + !showPlanPicker && + !switchPreview && + (isLoadingSummary || subscriptionSummary != null); + const isPlanPickerView = !switchPreview && !showOverview; + const isSwitchToFree = switchPreview?.newPlanId === "free"; let dialogTitle: string; let dialogDescription: string; let dialogFooter: React.ReactNode; let dialogOnBack: (() => void) | undefined; - if (downgradePreview) { - dialogTitle = "Downgrade Plan"; - dialogDescription = `Downgrade from ${downgradePreview.currentPlanName} to Free`; - dialogOnBack = () => setDowngradePreview(null); - dialogFooter = ( + const isBillingCycleSwitch = + switchPreview != null && + switchPreview.currentPlanId === switchPreview.newPlanId; + + if (switchPreview) { + const billingCycleLabel = + BILLING_INTERVAL_DISPLAY[switchPreview.billingInterval]; + dialogTitle = "Switch Plan"; + dialogDescription = isBillingCycleSwitch + ? `Switch to ${billingCycleLabel} plan` + : `Switch from ${switchPreview.currentPlanName} to ${switchPreview.newPlanName}`; + dialogOnBack = () => setSwitchPreview(null); + dialogFooter = isSwitchToFree ? ( - ); - } else if (switchPreview) { - dialogTitle = "Switch Plan"; - dialogDescription = `Switch from ${switchPreview.currentPlanName} to ${switchPreview.newPlanName}`; - dialogOnBack = () => setSwitchPreview(null); - dialogFooter = ( + ) : ( ); + } else if (showOverview) { + dialogTitle = "Your Plan"; + dialogDescription = "Manage your current subscription."; + dialogFooter = ( + + ); } else { dialogTitle = "Subscribe to Serial"; dialogDescription = "All prices are taxes-included."; + dialogOnBack = isSubscribed ? () => setShowPlanPicker(false) : undefined; dialogFooter = (

Price too high or need higher limits?{" "} @@ -1056,9 +1244,17 @@ export function SubscriptionDialog({ } function handleOpenChange(nextOpen: boolean) { - if (!nextOpen && isSubView) { - setDowngradePreview(null); - setSwitchPreview(null); + if (!nextOpen) { + if (switchPreview) { + setSwitchPreview(null); + return; + } + if (showPlanPicker && isSubscribed) { + setShowPlanPicker(false); + return; + } + setShowPlanPicker(false); + onOpenChange(false); return; } onOpenChange(nextOpen); @@ -1075,15 +1271,12 @@ export function SubscriptionDialog({ onOpenChange={handleOpenChange} title={dialogTitle} description={dialogDescription} - className={isSubView ? undefined : "lg:max-w-5xl xl:max-w-6xl"} - headerClassName={isSubView ? undefined : "lg:text-center"} - footerBorder={isSubView} + className={isPlanPickerView ? "lg:max-w-5xl xl:max-w-6xl" : undefined} + headerClassName={isPlanPickerView ? "lg:text-center" : undefined} onBack={dialogOnBack} footer={dialogFooter} > - {downgradePreview ? ( - - ) : switchPreview ? ( + {switchPreview ? ( @@ -1098,7 +1291,30 @@ export function SubscriptionDialog({ } monthlyPrice={newPlanProduct?.monthlyPrice ?? null} annualPrice={newPlanProduct?.annualPrice ?? null} + currentBillingInterval={ + (subscriptionSummary?.billingInterval as BillingInterval | null) ?? + null + } /> + ) : showOverview ? ( + isLoadingSummary ? ( +

+ +
+ + + +
+
+ ) : subscriptionSummary ? ( + setShowPlanPicker(true)} + onCancelPending={() => revertPendingMutation.mutate({})} + isCancelPending={revertPendingMutation.isPending} + /> + ) : null ) : (
{showVerification && !emailVerified && ( diff --git a/src/components/feed/SubscriptionDialogContext.tsx b/src/components/feed/SubscriptionDialogContext.tsx index 1eaf334e..cf27de6d 100644 --- a/src/components/feed/SubscriptionDialogContext.tsx +++ b/src/components/feed/SubscriptionDialogContext.tsx @@ -2,7 +2,7 @@ import { createContext, useContext } from "react"; import type { UseMutationResult } from "@tanstack/react-query"; -import type { DowngradePreview, SwitchPreview } from "./SubscriptionDialog"; +import type { SwitchPreview } from "./SubscriptionDialog"; type MutationResult = UseMutationResult; @@ -28,8 +28,12 @@ interface SubscriptionDialogContextValue { // Preview states switchPreview: SwitchPreview | null; - downgradePreview: DowngradePreview | null; - pendingSwitch: { planId: string; appliesAt: string } | null; + downgradePreview: SwitchPreview | null; + pendingSwitch: { + planId: string; + billingInterval: "month" | "year" | null; + appliesAt: string; + } | null; pendingPlanId: | "standard-small" | "standard-medium" @@ -42,13 +46,13 @@ interface SubscriptionDialogContextValue { previewMutation: MutationResult; switchMutation: MutationResult<{ success: boolean }>; portalMutation: MutationResult<{ url?: string }>; - downgradePreviewMutation: MutationResult; + downgradePreviewMutation: MutationResult; cancelMutation: MutationResult<{ success: boolean }>; // Actions setShowVerification: (show: boolean) => void; setSwitchPreview: (preview: SwitchPreview | null) => void; - setDowngradePreview: (preview: DowngradePreview | null) => void; + setDowngradePreview: (preview: SwitchPreview | null) => void; setPendingPlanId: ( id: "standard-small" | "standard-medium" | "standard-large" | "pro" | null, ) => void; diff --git a/src/components/feed/dialogStore.ts b/src/components/feed/dialogStore.ts index bf797613..e2a43257 100644 --- a/src/components/feed/dialogStore.ts +++ b/src/components/feed/dialogStore.ts @@ -8,16 +8,28 @@ export type DialogType = | "edit-user-profile" | "connections" | "subscription"; + +export type SubscriptionView = "overview" | "picker"; + type DialogStore = { dialog: null | DialogType; - launchDialog: (dialog: DialogType) => void; + subscriptionView: SubscriptionView; + launchDialog: ( + dialog: DialogType, + options?: { subscriptionView?: SubscriptionView }, + ) => void; closeDialog: () => void; onOpenChange: (open: boolean) => void; }; export const useDialogStore = create((set) => ({ dialog: null, - launchDialog: (dialog: DialogType) => set({ dialog }), + subscriptionView: "overview", + launchDialog: (dialog, options) => + set({ + dialog, + subscriptionView: options?.subscriptionView ?? "overview", + }), closeDialog: () => set({ dialog: null }), onOpenChange: () => set({ dialog: null }), })); diff --git a/src/components/ui/card-radio-group.tsx b/src/components/ui/card-radio-group.tsx index 2955c0b3..fdaca3d0 100644 --- a/src/components/ui/card-radio-group.tsx +++ b/src/components/ui/card-radio-group.tsx @@ -7,6 +7,7 @@ export type CardRadioOption = { value: T; title: string; description?: string; + disabled?: boolean; }; type CardRadioGroupProps = { @@ -36,20 +37,27 @@ export function CardRadioGroup({ > {options.map((option) => { const isSelected = option.value === value; + const isDisabled = option.disabled === true; const id = `card-radio-${option.value}`; return ( ); } diff --git a/tests/e2e/main-instance/feed-limit.spec.ts b/tests/e2e/main-instance/feed-limit.spec.ts index 51a65eb5..5f4da68e 100644 --- a/tests/e2e/main-instance/feed-limit.spec.ts +++ b/tests/e2e/main-instance/feed-limit.spec.ts @@ -51,9 +51,12 @@ test.describe("feed limit for free plan", () => { const dropzone = page.getByText(/drag and drop/i); await expect(dropzone).toBeVisible(); + await page.locator('input[data-ready="true"]').waitFor({ timeout: 10000 }); - // Upload the 50-feed OPML directly to the file input - await page.locator('input[type="file"]').setInputFiles({ + const fileChooserPromise = page.waitForEvent("filechooser"); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles({ name: "subscriptions.opml", mimeType: "application/xml", buffer: generateOpml(TOTAL_FEEDS), diff --git a/tests/e2e/self-hosted/import-flow.spec.ts b/tests/e2e/self-hosted/import-flow.spec.ts index 7a1a90d5..c34ae5df 100644 --- a/tests/e2e/self-hosted/import-flow.spec.ts +++ b/tests/e2e/self-hosted/import-flow.spec.ts @@ -48,7 +48,7 @@ test.describe("full user lifecycle", () => { const dropzone = page.getByText(/drag and drop/i); await expect(dropzone).toBeVisible(); - await page.waitForTimeout(1000); + await page.locator('input[data-ready="true"]').waitFor({ timeout: 10000 }); const fileChooserPromise = page.waitForEvent("filechooser"); await dropzone.click(); diff --git a/tests/e2e/self-hosted/import-modes.spec.ts b/tests/e2e/self-hosted/import-modes.spec.ts index 46b49726..50961cef 100644 --- a/tests/e2e/self-hosted/import-modes.spec.ts +++ b/tests/e2e/self-hosted/import-modes.spec.ts @@ -28,7 +28,7 @@ async function importSectionedOpml( const dropzone = page.getByText(/drag and drop/i); await expect(dropzone).toBeVisible(); - await page.waitForTimeout(500); + await page.locator('input[data-ready="true"]').waitFor({ timeout: 10000 }); const fileChooserPromise = page.waitForEvent("filechooser"); await dropzone.click(); From 6681df291891e291a4693a2722855547ceaeb32f Mon Sep 17 00:00:00 2001 From: Henry <48483883+hfellerhoff@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:18:30 -0400 Subject: [PATCH 27/29] code review --- src/app/_app.tsx | 2 +- .../subscription-dialog/StandardPlanCards.tsx | 16 +++- .../SubscriptionDialog.tsx | 18 ++-- .../feed/subscription-dialog/constants.ts | 5 -- .../feed/subscription-dialog/context.tsx | 5 +- .../feed/subscription-dialog/utils.ts | 30 ++++--- src/env.js | 1 + src/server/api/routers/subscriptionRouter.ts | 88 ++++++++++--------- src/server/auth/index.tsx | 8 +- src/server/email.ts | 11 ++- src/server/subscriptions/plans.ts | 8 ++ 11 files changed, 105 insertions(+), 87 deletions(-) diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 7d800ae7..da8b5aef 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -181,7 +181,7 @@ function CheckoutSuccessDialog({ const { planId } = useSubscription(); const plan = PLANS[planId]; const features = getPlanFeatures(plan); - const Icon = PLAN_ICONS[planId]; + const Icon = PLAN_ICONS[planId] ?? PLAN_ICONS.free; return ( !f.startsWith("Up to"), + ); + return (
@@ -42,7 +52,7 @@ export function StandardPlanCards() {

Standard