feat(telegram): integrate Telegram bot for notifications and add related models
This commit is contained in:
@@ -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" }}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user