aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/settings/layout.tsx132
-rw-r--r--apps/web/app/settings/subscription/page.tsx18
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx233
-rw-r--r--apps/web/components/subscription/QuotaProgress.tsx177
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json25
5 files changed, 527 insertions, 58 deletions
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: <ArrowLeft size={18} />,
- path: "/dashboard/bookmarks",
- },
- {
- name: t("settings.info.user_info"),
- icon: <User size={18} />,
- path: "/settings/info",
- },
- {
- name: t("settings.stats.usage_statistics"),
- icon: <BarChart3 size={18} />,
- path: "/settings/stats",
- },
- {
- name: t("settings.ai.ai_settings"),
- icon: <Sparkles size={18} />,
- path: "/settings/ai",
- },
- {
- name: t("settings.feeds.rss_subscriptions"),
- icon: <Rss size={18} />,
- path: "/settings/feeds",
- },
- {
- name: t("settings.import.import_export"),
- icon: <Download size={18} />,
- path: "/settings/import",
- },
- {
- name: t("settings.api_keys.api_keys"),
- icon: <KeyRound size={18} />,
- path: "/settings/api-keys",
- },
- {
- name: t("settings.broken_links.broken_links"),
- icon: <Link size={18} />,
- path: "/settings/broken-links",
- },
- {
- name: t("settings.webhooks.webhooks"),
- icon: <Webhook size={18} />,
- path: "/settings/webhooks",
- },
- {
- name: t("settings.rules.rules"),
- icon: <GitBranch size={18} />,
- path: "/settings/rules",
- },
- {
- name: t("settings.manage_assets.manage_assets"),
- icon: <Image size={18} />,
- path: "/settings/assets",
- },
-];
+}[] => {
+ const baseItems = [
+ {
+ name: t("settings.back_to_app"),
+ icon: <ArrowLeft size={18} />,
+ path: "/dashboard/bookmarks",
+ },
+ {
+ name: t("settings.info.user_info"),
+ icon: <User size={18} />,
+ path: "/settings/info",
+ },
+ {
+ name: t("settings.stats.usage_statistics"),
+ icon: <BarChart3 size={18} />,
+ path: "/settings/stats",
+ },
+ ];
+
+ // Add subscription item if Stripe is configured
+ if (serverConfig.stripe.isConfigured) {
+ baseItems.push({
+ name: t("settings.subscription.subscription"),
+ icon: <CreditCard size={18} />,
+ path: "/settings/subscription",
+ });
+ }
+
+ return [
+ ...baseItems,
+ {
+ name: t("settings.ai.ai_settings"),
+ icon: <Sparkles size={18} />,
+ path: "/settings/ai",
+ },
+ {
+ name: t("settings.feeds.rss_subscriptions"),
+ icon: <Rss size={18} />,
+ path: "/settings/feeds",
+ },
+ {
+ name: t("settings.import.import_export"),
+ icon: <Download size={18} />,
+ path: "/settings/import",
+ },
+ {
+ name: t("settings.api_keys.api_keys"),
+ icon: <KeyRound size={18} />,
+ path: "/settings/api-keys",
+ },
+ {
+ name: t("settings.broken_links.broken_links"),
+ icon: <Link size={18} />,
+ path: "/settings/broken-links",
+ },
+ {
+ name: t("settings.webhooks.webhooks"),
+ icon: <Webhook size={18} />,
+ path: "/settings/webhooks",
+ },
+ {
+ name: t("settings.rules.rules"),
+ icon: <GitBranch size={18} />,
+ path: "/settings/rules",
+ },
+ {
+ name: t("settings.manage_assets.manage_assets"),
+ icon: <Image size={18} />,
+ 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 (
+ <div className="flex flex-col gap-4">
+ <SubscriptionSettings />
+ <QuotaProgress />
+ </div>
+ );
+}
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 (
+ <Badge variant="default" className="bg-green-500">
+ {t("settings.subscription.paid")}
+ </Badge>
+ );
+ case "free":
+ return (
+ <Badge variant="outline">{t("settings.subscription.free")}</Badge>
+ );
+ }
+ };
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CreditCard className="h-5 w-5" />
+ {t("settings.subscription.subscription")}
+ </CardTitle>
+ <CardDescription>
+ {t("settings.subscription.manage_subscription")}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {isQueryLoading ? (
+ <div className="space-y-6">
+ <div className="grid gap-4 md:grid-cols-2">
+ <div className="space-y-2">
+ <Skeleton className="h-4 w-24" />
+ <div className="flex items-center gap-2">
+ <Skeleton className="h-5 w-16" />
+ <Skeleton className="h-5 w-12" />
+ </div>
+ </div>
+ <div className="space-y-2">
+ <Skeleton className="h-4 w-28" />
+ <Skeleton className="h-4 w-40" />
+ </div>
+ </div>
+ <div className="space-y-4">
+ <div className="rounded-lg border p-4">
+ <div className="flex items-center justify-between">
+ <div className="space-y-2">
+ <Skeleton className="h-6 w-24" />
+ <Skeleton className="h-4 w-64" />
+ <Skeleton className="h-6 w-20" />
+ </div>
+ <Skeleton className="h-10 w-32" />
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : (
+ <>
+ <div className="grid gap-4 md:grid-cols-2">
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ {t("settings.subscription.current_plan")}
+ </label>
+ <div className="flex items-center gap-2">
+ {subscriptionStatus?.tier &&
+ getStatusBadge(subscriptionStatus.tier)}
+ </div>
+ </div>
+
+ {subscriptionStatus?.hasActiveSubscription && (
+ <>
+ <div className="space-y-2">
+ <label className="text-sm font-medium">
+ {t("settings.subscription.billing_period")}
+ </label>
+ <div className="text-sm text-muted-foreground">
+ {formatDate(subscriptionStatus.startDate)} -{" "}
+ {formatDate(subscriptionStatus.endDate)}
+ </div>
+ </div>
+ </>
+ )}
+ </div>
+
+ <div className="space-y-4">
+ {!subscriptionStatus?.hasActiveSubscription ? (
+ <div className="space-y-4">
+ <div className="rounded-lg border p-4">
+ <div className="flex items-center justify-between">
+ <div>
+ <h3 className="flex items-center gap-2 font-semibold">
+ {t("settings.subscription.paid_plan")}
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ {t("settings.subscription.unlock_bigger_quota")}
+ </p>
+ {subscriptionPrice && subscriptionPrice.amount ? (
+ <p className="mt-2 text-lg font-bold uppercase">
+ {subscriptionPrice.amount / 100}{" "}
+ {subscriptionPrice.currency}
+ </p>
+ ) : (
+ <Skeleton className="h-4 w-24" />
+ )}
+ </div>
+ <Button
+ onClick={() => createCheckoutSession.mutate()}
+ disabled={isLoading}
+ className="bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700"
+ >
+ {isLoading && (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ )}
+ {t("settings.subscription.subscribe_now")}
+ </Button>
+ </div>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex gap-2">
+ <Button
+ onClick={() => createPortalSession.mutate()}
+ disabled={isLoading}
+ variant="outline"
+ >
+ {isLoading && (
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ )}
+ {t("settings.subscription.manage_billing")}
+ </Button>
+ </div>
+
+ {subscriptionStatus.cancelAtPeriodEnd && (
+ <Alert>
+ <AlertDescription>
+ {t("settings.subscription.subscription_canceled", {
+ date: formatDate(subscriptionStatus.endDate),
+ })}
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+ )}
+ </div>
+ </>
+ )}
+ </CardContent>
+ </Card>
+ );
+}
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 (
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ {icon}
+ <h4 className="font-medium">{title}</h4>
+ </div>
+
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span className="text-muted-foreground">{description}</span>
+ <span className={isAtLimit ? "font-medium text-destructive" : ""}>
+ {formatter(used)}{" "}
+ {unlimited
+ ? ""
+ : `/ ${quota !== null && quota !== undefined ? formatter(quota) : "∞"}`}
+ </span>
+ </div>
+
+ {!unlimited && quota && (
+ <Progress
+ value={percentage}
+ className={`h-2 ${
+ isAtLimit
+ ? "[&>div]:bg-destructive"
+ : isNearLimit
+ ? "[&>div]:bg-orange-500"
+ : ""
+ }`}
+ />
+ )}
+
+ {unlimited && (
+ <div className="text-xs text-muted-foreground">
+ {t("settings.subscription.unlimited_usage")}
+ </div>
+ )}
+
+ {isAtLimit && (
+ <div className="text-xs text-destructive">
+ {t("settings.subscription.quota_limit_reached")}
+ </div>
+ )}
+
+ {isNearLimit && !isAtLimit && (
+ <div className="text-xs text-orange-600">
+ {t("settings.subscription.approaching_quota_limit")}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+export function QuotaProgress() {
+ const { t } = useTranslation();
+ const { data: quotaUsage, isLoading } =
+ api.subscriptions.getQuotaUsage.useQuery();
+
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle>
+ <CardDescription>
+ {t("settings.subscription.loading_usage")}
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ <div className="animate-pulse space-y-2">
+ <div className="h-4 w-1/3 rounded bg-muted"></div>
+ <div className="h-2 rounded bg-muted"></div>
+ </div>
+ <div className="animate-pulse space-y-2">
+ <div className="h-4 w-1/3 rounded bg-muted"></div>
+ <div className="h-2 rounded bg-muted"></div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+ }
+
+ if (!quotaUsage) {
+ return null;
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>{t("settings.subscription.usage_quotas")}</CardTitle>
+ <CardDescription>
+ {t("settings.subscription.track_usage")}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <QuotaProgressItem
+ title="Bookmarks"
+ icon={<Database className="h-4 w-4" />}
+ used={quotaUsage.bookmarks.used}
+ quota={quotaUsage.bookmarks.quota}
+ unlimited={quotaUsage.bookmarks.unlimited}
+ formatter={formatNumber}
+ description={t("settings.subscription.total_bookmarks_saved")}
+ />
+
+ <QuotaProgressItem
+ title="Storage"
+ icon={<HardDrive className="h-4 w-4" />}
+ used={quotaUsage.storage.used}
+ quota={quotaUsage.storage.quota}
+ unlimited={quotaUsage.storage.unlimited}
+ formatter={formatBytes}
+ description={t("settings.subscription.assets_file_storage")}
+ />
+ </CardContent>
+ </Card>
+ );
+}
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": {