aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/settings/SubscriptionSettings.tsx233
-rw-r--r--apps/web/components/subscription/QuotaProgress.tsx177
2 files changed, 410 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>
+ );
+}
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>
+ );
+}