diff --git a/app/dashboard/page-client.tsx b/app/dashboard/page-client.tsx index 9cb5222..5025a4c 100644 --- a/app/dashboard/page-client.tsx +++ b/app/dashboard/page-client.tsx @@ -33,10 +33,11 @@ import { api } from "@/convex/_generated/api"; import type { Doc } from "@/convex/_generated/dataModel"; import { authClient } from "@/lib/auth-client"; import { CreditOverview } from "@/components/dashboard/credit-overview"; +import { CreditsActivityChart } from "@/components/dashboard/credits-activity-chart"; import { RecentTransactions } from "@/components/dashboard/recent-transactions"; import CanvasCard from "@/components/dashboard/canvas-card"; +import { useDashboardSnapshot } from "@/hooks/use-dashboard-snapshot"; import { toast } from "@/lib/toast"; -import { useAuthQuery } from "@/hooks/use-auth-query"; function getInitials(nameOrEmail: string) { const normalized = nameOrEmail.trim(); @@ -56,10 +57,7 @@ export function DashboardPageClient() { const welcomeToastSentRef = useRef(false); const { theme = "system", setTheme } = useTheme(); const { data: session, isPending: isSessionPending } = authClient.useSession(); - const canvases = useAuthQuery( - api.canvases.list, - session?.user && !isSessionPending ? {} : "skip", - ); + const { snapshot: dashboardSnapshot } = useDashboardSnapshot(session?.user?.id); const createCanvas = useMutation(api.canvases.create); const [isCreatingWorkspace, setIsCreatingWorkspace] = useState(false); const [hasClientMounted, setHasClientMounted] = useState(false); @@ -70,6 +68,7 @@ export function DashboardPageClient() { const displayName = session?.user.name?.trim() || session?.user.email || "Nutzer"; const initials = getInitials(displayName); + const canvases = dashboardSnapshot?.canvases; useEffect(() => { if (!session?.user || welcomeToastSentRef.current) return; @@ -190,7 +189,11 @@ export function DashboardPageClient() { Credit-Übersicht - +
@@ -238,8 +241,12 @@ export function DashboardPageClient() { )}
-
- +
+ +
diff --git a/components/dashboard/credit-overview.tsx b/components/dashboard/credit-overview.tsx index c7c31cd..ad017db 100644 --- a/components/dashboard/credit-overview.tsx +++ b/components/dashboard/credit-overview.tsx @@ -1,8 +1,7 @@ "use client"; import { useEffect } from "react"; -import { useAuthQuery } from "@/hooks/use-auth-query"; -import { useFormatter, useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { CreditCard } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -10,8 +9,9 @@ import { useRouter } from "next/navigation"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; -import { api } from "@/convex/_generated/api"; -import { normalizeTier, TIER_MONTHLY_CREDITS } from "@/lib/polar-products"; +import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot"; +import { normalizeTier } from "@/lib/polar-products"; +import { formatCredits } from "@/lib/credits-activity"; import { cn } from "@/lib/utils"; import { toast } from "@/lib/toast"; @@ -28,16 +28,16 @@ const TIER_BADGE_STYLES: Record = { const LOW_CREDITS_THRESHOLD = 20; -export function CreditOverview() { +type CreditOverviewProps = { + balance?: DashboardSnapshot["balance"]; + subscription?: DashboardSnapshot["subscription"]; + usageStats?: DashboardSnapshot["usageStats"]; +}; + +export function CreditOverview({ balance, subscription, usageStats }: CreditOverviewProps) { const t = useTranslations('toasts'); const router = useRouter(); - const format = useFormatter(); - - const formatEurFromCents = (cents: number) => - format.number(cents / 100, { style: "currency", currency: "EUR" }); - const balance = useAuthQuery(api.credits.getBalance); - const subscription = useAuthQuery(api.credits.getSubscription); - const usageStats = useAuthQuery(api.credits.getUsageStats); + const locale = useLocale(); useEffect(() => { if (balance === undefined) return; @@ -79,7 +79,7 @@ export function CreditOverview() { // ── Computed Values ──────────────────────────────────────────────────── const tier = normalizeTier(subscription.tier); - const monthlyCredits = TIER_MONTHLY_CREDITS[tier]; + const monthlyCredits = usageStats.monthlyCredits; const usagePercent = monthlyCredits > 0 ? Math.min(100, Math.round((usageStats.monthlyUsage / monthlyCredits) * 100)) : 0; @@ -98,9 +98,9 @@ export function CreditOverview() {

Verfügbare Credits

- - {formatEurFromCents(balance.available)} - + + {formatCredits(balance.available, locale)} + {balance.reserved > 0 && (

- ({formatEurFromCents(balance.reserved)} reserviert) + ({formatCredits(balance.reserved, locale)} reserviert)

)}
@@ -132,8 +132,8 @@ export function CreditOverview() { />
- {formatEurFromCents(usageStats.monthlyUsage)} von{" "} - {formatEurFromCents(monthlyCredits)} verwendet + {formatCredits(usageStats.monthlyUsage, locale)} von{" "} + {formatCredits(monthlyCredits, locale)} verwendet {usageStats.totalGenerations} Generierungen diff --git a/components/dashboard/credits-activity-chart.tsx b/components/dashboard/credits-activity-chart.tsx new file mode 100644 index 0000000..e6f1985 --- /dev/null +++ b/components/dashboard/credits-activity-chart.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { useMemo } from "react"; +import { useLocale } from "next-intl"; +import { Area, AreaChart, CartesianGrid, Line, XAxis, YAxis } from "recharts"; +import { ChartNoAxesCombined } from "lucide-react"; + +import { + buildCreditsActivitySeries, + calculateUsageActivityDomain, + formatCredits, + prioritizeRecentCreditTransactions, +} from "@/lib/credits-activity"; +import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot"; +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; + +const chartConfig = { + usage: { + label: "Verbrauch", + color: "hsl(var(--primary))", + }, + activity: { + label: "Aktivität", + color: "hsl(var(--accent-foreground))", + }, + available: { + label: "Verfügbar", + color: "hsl(var(--muted-foreground))", + }, +} satisfies ChartConfig; + +type CreditsActivityChartProps = { + balance?: DashboardSnapshot["balance"]; + recentTransactions?: DashboardSnapshot["recentTransactions"]; +}; + +export function CreditsActivityChart({ balance, recentTransactions }: CreditsActivityChartProps) { + const locale = useLocale(); + + const chartData = useMemo(() => { + if (balance === undefined || recentTransactions === undefined) { + return []; + } + + const prioritized = prioritizeRecentCreditTransactions(recentTransactions, 40); + + return buildCreditsActivitySeries(prioritized, balance.available, locale, 7); + }, [balance, recentTransactions, locale]); + + const usageDomain = useMemo( + () => calculateUsageActivityDomain(chartData), + [chartData], + ); + + if (balance === undefined || recentTransactions === undefined) { + return ( +
+
+
+ ); + } + + if (chartData.length === 0) { + return ( +
+
+ + Credits Verlauf +
+

Noch keine verbrauchsrelevante Aktivität vorhanden.

+
+ ); + } + + return ( +
+
+
+ + Credits Verlauf +
+ + Verfügbar: {formatCredits(balance.available, locale)} + +
+ + + + + + + + formatCredits(Number(value), locale)} + /> + } + /> + + + + } /> + + +
+ ); +} diff --git a/components/dashboard/recent-transactions.tsx b/components/dashboard/recent-transactions.tsx index 70fd0c8..d0ba77d 100644 --- a/components/dashboard/recent-transactions.tsx +++ b/components/dashboard/recent-transactions.tsx @@ -1,12 +1,12 @@ "use client"; -import { useAuthQuery } from "@/hooks/use-auth-query"; -import { useFormatter } from "next-intl"; +import { useFormatter, useLocale } from "next-intl"; import { Activity, Coins } from "lucide-react"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; -import { api } from "@/convex/_generated/api"; +import type { DashboardSnapshot } from "@/hooks/use-dashboard-snapshot"; +import { formatCredits } from "@/lib/credits-activity"; import { cn } from "@/lib/utils"; // --------------------------------------------------------------------------- @@ -45,12 +45,14 @@ function truncatedDescription(text: string, maxLen = 40) { // Component // --------------------------------------------------------------------------- -export function RecentTransactions() { +type RecentTransactionsProps = { + recentTransactions?: DashboardSnapshot["recentTransactions"]; +}; + +export function RecentTransactions({ recentTransactions }: RecentTransactionsProps) { const format = useFormatter(); + const locale = useLocale(); const [now, setNow] = useState(null); - const transactions = useAuthQuery(api.credits.getRecentTransactions, { - limit: 10, - }); useEffect(() => { const updateNow = () => { @@ -67,8 +69,7 @@ export function RecentTransactions() { }; }, []); - const formatEurFromCents = (cents: number) => - format.number(cents / 100, { style: "currency", currency: "EUR" }); + const transactions = recentTransactions ?? []; const formatRelativeTime = (timestamp: number) => { const referenceNow = now ?? timestamp; @@ -85,7 +86,7 @@ export function RecentTransactions() { }; // ── Loading State ────────────────────────────────────────────────────── - if (transactions === undefined) { + if (recentTransactions === undefined) { return (
@@ -173,7 +174,7 @@ export function RecentTransactions() { )} > {isCredit ? "+" : "−"} - {formatEurFromCents(Math.abs(t.amount))} + {formatCredits(Math.abs(t.amount), locale)}
diff --git a/components/ui/chart.tsx b/components/ui/chart.tsx new file mode 100644 index 0000000..7b06ddb --- /dev/null +++ b/components/ui/chart.tsx @@ -0,0 +1,166 @@ +"use client"; + +import * as React from "react"; +import * as RechartsPrimitive from "recharts"; + +import { cn } from "@/lib/utils"; + +export type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + color?: string; + }; +}; + +type ChartContextProps = { + config: ChartConfig; +}; + +const ChartContext = React.createContext(null); + +function useChart() { + const context = React.useContext(ChartContext); + + if (!context) { + throw new Error("useChart must be used within a "); + } + + return context; +} + +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<"div"> & { + config: ChartConfig; + children: React.ComponentProps["children"]; +}) { + const uniqueId = React.useId(); + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`; + + return ( + +
+ + {children} +
+
+ ); +} + +function ChartStyle({ id, config }: { id: string; config: ChartConfig }) { + const colorConfig = Object.entries(config).filter(([, item]) => item.color); + + if (!colorConfig.length) { + return null; + } + + return ( +