diff options
Diffstat (limited to 'apps/web/components')
| -rw-r--r-- | apps/web/components/settings/SubscriptionSettings.tsx | 233 | ||||
| -rw-r--r-- | apps/web/components/subscription/QuotaProgress.tsx | 177 |
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> + ); +} |
