feat(telegram): integrate Telegram bot for notifications and add related models

This commit is contained in:
Jose Selesan
2026-06-17 08:42:13 -03:00
parent c0c8bf0945
commit b8d8f16f4e
14 changed files with 493 additions and 3 deletions

View File

@@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { BarChart3, RefreshCw } from "lucide-react";
import { BarChart3, MessageCircle, RefreshCw } from "lucide-react";
import { useMemo, useState } from "react";
import {
CartesianGrid,
@@ -24,6 +24,7 @@ import {
useFetchQuotes,
useQuoteHistory,
useQuotes,
useTelegramStatus,
} from "@/lib/queries";
import { RelativeTime } from "@/lib/time";
import { cn } from "@/lib/utils";
@@ -57,6 +58,32 @@ function getGaugeColor(percentage: number | null): string {
return "text-red-500";
}
function TelegramBadge() {
const { data: status } = useTelegramStatus();
if (status?.connected) {
return (
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400 hover:underline"
>
<MessageCircle className="h-3.5 w-3.5" />
Telegram conectado
</Link>
);
}
return (
<Link
to="/settings"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<MessageCircle className="h-3.5 w-3.5" />
Conectar Telegram
</Link>
);
}
function StatCard({
label,
buy,
@@ -231,6 +258,7 @@ export function BeloPage() {
Últ. actualización: <RelativeTime date={belo.timeStamp} />
</span>
)}
<TelegramBadge />
<Link
to="/quotes/analysis/$currency"
params={{ currency: "BELO" }}

View File

@@ -319,3 +319,23 @@ export function importPeriodicExpenses(file: File): Promise<ImportResult> {
return r.json();
});
}
// Telegram
export type TelegramStatus = {
botUsername: string | null;
connected: boolean;
};
export type TelegramCode = {
code: string;
botUsername: string | null;
};
export function getTelegramStatus(): Promise<TelegramStatus> {
return fetcher<TelegramStatus>("/api/telegram/status");
}
export function generateTelegramCode(): Promise<TelegramCode> {
return mutator<TelegramCode>("/api/telegram/code", "POST");
}

View File

@@ -15,6 +15,7 @@ import {
getDailyMinMax,
getDailyQuotes,
getExpenses,
generateTelegramCode,
getHistoricalMinMax,
getMonthlyPayedTotal,
getMonthlyTotals,
@@ -22,6 +23,7 @@ import {
getPeriodicExpenses,
getQuoteHistory,
getQuotes,
getTelegramStatus,
getTotalPending,
importExpenses,
importPeriodicExpenses,
@@ -292,3 +294,23 @@ export function useUpdateExpense() {
},
});
}
// Telegram
export function useTelegramStatus() {
return useQuery({
queryKey: ["telegram", "status"],
queryFn: getTelegramStatus,
staleTime: 30_000,
});
}
export function useGenerateTelegramCode() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: generateTelegramCode,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["telegram", "status"] });
},
});
}

View File

@@ -1,8 +1,13 @@
import { createFileRoute } from "@tanstack/react-router";
import { Check, Copy, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuth } from "@/lib/auth-context";
import {
useGenerateTelegramCode,
useTelegramStatus,
} from "@/lib/queries";
export const Route = createFileRoute("/_authenticated/settings")({
component: SettingsPage,
@@ -48,7 +53,7 @@ function SettingsPage() {
}
return (
<div className="mx-auto max-w-md space-y-6">
<div className="mx-auto max-w-md space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Configuración</h1>
<p className="text-sm text-muted-foreground">Cambiar contraseña</p>
@@ -118,6 +123,124 @@ function SettingsPage() {
{loading ? "Guardando..." : "Cambiar contraseña"}
</Button>
</form>
<hr className="border-border" />
<TelegramSection />
</div>
);
}
function TelegramSection() {
const { data: status, isLoading } = useTelegramStatus();
const {
mutate: generateCode,
data: newCode,
isPending: generating,
reset: resetCode,
} = useGenerateTelegramCode();
const [copied, setCopied] = useState(false);
const code = newCode?.code;
async function handleCopy() {
if (!code) return;
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// fallback
const el = document.createElement("textarea");
el.value = code;
document.body.appendChild(el);
el.select();
document.execCommand("copy");
document.body.removeChild(el);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
function handleGenerate() {
resetCode();
generateCode();
}
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold tracking-tight">Telegram</h2>
<p className="text-sm text-muted-foreground">
Conectá tu cuenta de Telegram para recibir notificaciones
</p>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : status?.connected ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-800 dark:bg-emerald-950/30">
<p className="text-sm font-medium text-emerald-700 dark:text-emerald-400">
Conectado a Telegram
</p>
<p className="mt-1 text-xs text-emerald-600 dark:text-emerald-500">
Recibirás notificaciones de BELO aquí.
</p>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
{status?.botUsername ? (
<>
Enviá el código a{" "}
<strong className="font-medium text-foreground">
@{status.botUsername}
</strong>{" "}
en Telegram para conectar.
</>
) : (
"El bot no está disponible."
)}
</p>
{code ? (
<div className="flex items-center gap-2">
<code className="flex-1 rounded-md border bg-muted px-3 py-2 text-sm font-mono tracking-widest">
{code}
</code>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopy}
className="shrink-0"
>
{copied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
) : (
<Button
type="button"
onClick={handleGenerate}
disabled={generating || !status?.botUsername}
className="w-full"
>
{generating ? (
<>
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
Generando...
</>
) : (
"Generar código"
)}
</Button>
)}
</div>
)}
</div>
);
}