aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components/settings
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-07-13 09:28:24 +0000
committerMohamed Bassem <me@mbassem.com>2025-07-13 20:44:00 +0000
commitd1d5263486f96db578aad918a59007045c3c077f (patch)
treedf65f062b6eda93364f7d509fc2c52663561097a /apps/web/components/settings
parent845ccf1ad46c8635782f8e10280b07c48c08eaf5 (diff)
downloadkarakeep-d1d5263486f96db578aad918a59007045c3c077f.tar.zst
feat: Add stripe based subscriptions
Diffstat (limited to 'apps/web/components/settings')
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx233
1 files changed, 233 insertions, 0 deletions
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>
+ );
+}