From d1d5263486f96db578aad918a59007045c3c077f Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sun, 13 Jul 2025 09:28:24 +0000 Subject: feat: Add stripe based subscriptions --- apps/web/app/settings/layout.tsx | 132 +++++++----- apps/web/app/settings/subscription/page.tsx | 18 ++ .../components/settings/SubscriptionSettings.tsx | 233 +++++++++++++++++++++ apps/web/components/subscription/QuotaProgress.tsx | 177 ++++++++++++++++ apps/web/lib/i18n/locales/en/translation.json | 25 ++- 5 files changed, 527 insertions(+), 58 deletions(-) create mode 100644 apps/web/app/settings/subscription/page.tsx create mode 100644 apps/web/components/settings/SubscriptionSettings.tsx create mode 100644 apps/web/components/subscription/QuotaProgress.tsx (limited to 'apps/web') diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 217e02ba..b9ba80a0 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -7,6 +7,7 @@ import { TFunction } from "i18next"; import { ArrowLeft, BarChart3, + CreditCard, Download, GitBranch, Image, @@ -18,69 +19,86 @@ import { Webhook, } from "lucide-react"; +import serverConfig from "@karakeep/shared/config"; + const settingsSidebarItems = ( t: TFunction, ): { name: string; icon: JSX.Element; path: string; -}[] => [ - { - name: t("settings.back_to_app"), - icon: , - path: "/dashboard/bookmarks", - }, - { - name: t("settings.info.user_info"), - icon: , - path: "/settings/info", - }, - { - name: t("settings.stats.usage_statistics"), - icon: , - path: "/settings/stats", - }, - { - name: t("settings.ai.ai_settings"), - icon: , - path: "/settings/ai", - }, - { - name: t("settings.feeds.rss_subscriptions"), - icon: , - path: "/settings/feeds", - }, - { - name: t("settings.import.import_export"), - icon: , - path: "/settings/import", - }, - { - name: t("settings.api_keys.api_keys"), - icon: , - path: "/settings/api-keys", - }, - { - name: t("settings.broken_links.broken_links"), - icon: , - path: "/settings/broken-links", - }, - { - name: t("settings.webhooks.webhooks"), - icon: , - path: "/settings/webhooks", - }, - { - name: t("settings.rules.rules"), - icon: , - path: "/settings/rules", - }, - { - name: t("settings.manage_assets.manage_assets"), - icon: , - path: "/settings/assets", - }, -]; +}[] => { + const baseItems = [ + { + name: t("settings.back_to_app"), + icon: , + path: "/dashboard/bookmarks", + }, + { + name: t("settings.info.user_info"), + icon: , + path: "/settings/info", + }, + { + name: t("settings.stats.usage_statistics"), + icon: , + path: "/settings/stats", + }, + ]; + + // Add subscription item if Stripe is configured + if (serverConfig.stripe.isConfigured) { + baseItems.push({ + name: t("settings.subscription.subscription"), + icon: , + path: "/settings/subscription", + }); + } + + return [ + ...baseItems, + { + name: t("settings.ai.ai_settings"), + icon: , + path: "/settings/ai", + }, + { + name: t("settings.feeds.rss_subscriptions"), + icon: , + path: "/settings/feeds", + }, + { + name: t("settings.import.import_export"), + icon: , + path: "/settings/import", + }, + { + name: t("settings.api_keys.api_keys"), + icon: , + path: "/settings/api-keys", + }, + { + name: t("settings.broken_links.broken_links"), + icon: , + path: "/settings/broken-links", + }, + { + name: t("settings.webhooks.webhooks"), + icon: , + path: "/settings/webhooks", + }, + { + name: t("settings.rules.rules"), + icon: , + path: "/settings/rules", + }, + { + name: t("settings.manage_assets.manage_assets"), + icon: , + path: "/settings/assets", + }, + ]; +}; export default async function SettingsLayout({ children, diff --git a/apps/web/app/settings/subscription/page.tsx b/apps/web/app/settings/subscription/page.tsx new file mode 100644 index 00000000..e8c46460 --- /dev/null +++ b/apps/web/app/settings/subscription/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; +import SubscriptionSettings from "@/components/settings/SubscriptionSettings"; +import { QuotaProgress } from "@/components/subscription/QuotaProgress"; + +import serverConfig from "@karakeep/shared/config"; + +export default async function SubscriptionPage() { + if (!serverConfig.stripe.isConfigured) { + redirect("/settings"); + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/settings/SubscriptionSettings.tsx b/apps/web/components/settings/SubscriptionSettings.tsx new file mode 100644 index 00000000..53f1caf4 --- /dev/null +++ b/apps/web/components/settings/SubscriptionSettings.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { CreditCard, Loader2 } from "lucide-react"; + +import { Alert, AlertDescription } from "../ui/alert"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Skeleton } from "../ui/skeleton"; +import { toast } from "../ui/use-toast"; + +export default function SubscriptionSettings() { + const { t } = useTranslation(); + const { + data: subscriptionStatus, + refetch, + isLoading: isQueryLoading, + } = api.subscriptions.getSubscriptionStatus.useQuery(); + + const { data: subscriptionPrice } = + api.subscriptions.getSubscriptionPrice.useQuery(); + + const { mutate: syncStripeState } = + api.subscriptions.syncWithStripe.useMutation({ + onSuccess: () => { + refetch(); + }, + }); + const createCheckoutSession = + api.subscriptions.createCheckoutSession.useMutation({ + onSuccess: (resp) => { + if (resp.url) { + window.location.href = resp.url; + } + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }); + const createPortalSession = api.subscriptions.createPortalSession.useMutation( + { + onSuccess: (resp) => { + if (resp.url) { + window.location.href = resp.url; + } + }, + onError: () => { + toast({ + description: t("common.something_went_wrong"), + variant: "destructive", + }); + }, + }, + ); + + const isLoading = + createCheckoutSession.isPending || createPortalSession.isPending; + + useEffect(() => { + syncStripeState(); + }, []); + + const formatDate = (date: Date | null) => { + if (!date) return "N/A"; + return new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }).format(date); + }; + + const getStatusBadge = (status: "free" | "paid") => { + switch (status) { + case "paid": + return ( + + {t("settings.subscription.paid")} + + ); + case "free": + return ( + {t("settings.subscription.free")} + ); + } + }; + + return ( + + + + + {t("settings.subscription.subscription")} + + + {t("settings.subscription.manage_subscription")} + + + + {isQueryLoading ? ( +
+
+
+ +
+ + +
+
+
+ + +
+
+
+
+
+
+ + + +
+ +
+
+
+
+ ) : ( + <> +
+
+ +
+ {subscriptionStatus?.tier && + getStatusBadge(subscriptionStatus.tier)} +
+
+ + {subscriptionStatus?.hasActiveSubscription && ( + <> +
+ +
+ {formatDate(subscriptionStatus.startDate)} -{" "} + {formatDate(subscriptionStatus.endDate)} +
+
+ + )} +
+ +
+ {!subscriptionStatus?.hasActiveSubscription ? ( +
+
+
+
+

+ {t("settings.subscription.paid_plan")} +

+

+ {t("settings.subscription.unlock_bigger_quota")} +

+ {subscriptionPrice && subscriptionPrice.amount ? ( +

+ {subscriptionPrice.amount / 100}{" "} + {subscriptionPrice.currency} +

+ ) : ( + + )} +
+ +
+
+
+ ) : ( +
+
+ +
+ + {subscriptionStatus.cancelAtPeriodEnd && ( + + + {t("settings.subscription.subscription_canceled", { + date: formatDate(subscriptionStatus.endDate), + })} + + + )} +
+ )} +
+ + )} +
+
+ ); +} diff --git a/apps/web/components/subscription/QuotaProgress.tsx b/apps/web/components/subscription/QuotaProgress.tsx new file mode 100644 index 00000000..525eae8f --- /dev/null +++ b/apps/web/components/subscription/QuotaProgress.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useTranslation } from "@/lib/i18n/client"; +import { api } from "@/lib/trpc"; +import { Database, HardDrive } from "lucide-react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../ui/card"; +import { Progress } from "../ui/progress"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i]; +} + +function formatNumber(num: number): string { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + "M"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + "K"; + } + return num.toString(); +} + +interface QuotaProgressItemProps { + title: string; + icon: React.ReactNode; + used: number; + quota: number | null; + unlimited: boolean; + formatter: (value: number) => string; + description: string; +} + +function QuotaProgressItem({ + title, + icon, + used, + quota, + unlimited, + formatter, + description, +}: QuotaProgressItemProps) { + const { t } = useTranslation(); + const percentage = + unlimited || !quota ? 0 : Math.min((used / quota) * 100, 100); + const isNearLimit = percentage > 80; + const isAtLimit = percentage >= 100; + + return ( +
+
+ {icon} +

{title}

+
+ +
+
+ {description} + + {formatter(used)}{" "} + {unlimited + ? "" + : `/ ${quota !== null && quota !== undefined ? formatter(quota) : "∞"}`} + +
+ + {!unlimited && quota && ( + div]:bg-destructive" + : isNearLimit + ? "[&>div]:bg-orange-500" + : "" + }`} + /> + )} + + {unlimited && ( +
+ {t("settings.subscription.unlimited_usage")} +
+ )} + + {isAtLimit && ( +
+ {t("settings.subscription.quota_limit_reached")} +
+ )} + + {isNearLimit && !isAtLimit && ( +
+ {t("settings.subscription.approaching_quota_limit")} +
+ )} +
+
+ ); +} + +export function QuotaProgress() { + const { t } = useTranslation(); + const { data: quotaUsage, isLoading } = + api.subscriptions.getQuotaUsage.useQuery(); + + if (isLoading) { + return ( + + + {t("settings.subscription.usage_quotas")} + + {t("settings.subscription.loading_usage")} + + + +
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + if (!quotaUsage) { + return null; + } + + return ( + + + {t("settings.subscription.usage_quotas")} + + {t("settings.subscription.track_usage")} + + + + } + used={quotaUsage.bookmarks.used} + quota={quotaUsage.bookmarks.quota} + unlimited={quotaUsage.bookmarks.unlimited} + formatter={formatNumber} + description={t("settings.subscription.total_bookmarks_saved")} + /> + + } + used={quotaUsage.storage.used} + quota={quotaUsage.storage.quota} + unlimited={quotaUsage.storage.unlimited} + formatter={formatBytes} + description={t("settings.subscription.assets_file_storage")} + /> + + + ); +} diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json index 33f4952c..f9e1d493 100644 --- a/apps/web/lib/i18n/locales/en/translation.json +++ b/apps/web/lib/i18n/locales/en/translation.json @@ -37,7 +37,9 @@ "text": "Text", "media": "Media" }, - "quota": "Quota" + "quota": "Quota", + "bookmarks": "Bookmarks", + "storage": "Storage" }, "layouts": { "masonry": "Masonry", @@ -283,6 +285,27 @@ "favourited": "A bookmark is favourited", "archived": "A bookmark is archived" } + }, + "subscription": { + "subscription": "Subscription", + "manage_subscription": "Manage your subscription and billing information", + "current_plan": "Current Plan", + "billing_period": "Billing Period", + "paid_plan": "Paid Plan", + "unlock_bigger_quota": "Unlock bigger quota and support the project", + "subscribe_now": "Subscribe Now", + "manage_billing": "Manage Billing", + "subscription_canceled": "Your subscription has been canceled and will end on {{date}}. You can resubscribe at any time.", + "usage_quotas": "Usage & Quotas", + "track_usage": "Track your current usage against your plan limits", + "total_bookmarks_saved": "Total bookmarks saved", + "assets_file_storage": "Assets and file storage", + "unlimited_usage": "Unlimited usage", + "quota_limit_reached": "Quota limit reached", + "approaching_quota_limit": "Approaching quota limit", + "loading_usage": "Loading usage information...", + "free": "Free", + "paid": "Paid" } }, "admin": { -- cgit v1.2.3-70-g09d2