@@ -177,7 +170,7 @@ export function PerformanceGrid({
Current Price
diff --git a/src/components/dashboard/wallet/PlanSummaryCard.tsx b/src/components/dashboard/wallet/PlanSummaryCard.tsx
index 107114d..64d0a32 100644
--- a/src/components/dashboard/wallet/PlanSummaryCard.tsx
+++ b/src/components/dashboard/wallet/PlanSummaryCard.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import Link from "next/link";
import { Card, Badge, Cell, Navigation } from "@telegram-apps/telegram-ui";
+import { ValueSkeleton } from "@/components/ui/value-skeleton";
import { fmtLkrCurrency, fmtShortDate, fmtRelativeDays } from "@/lib/formatters";
import type { Subscription } from "@/lib/types";
@@ -31,10 +32,12 @@ function RewardCard({
label,
dateStr,
iconSrc,
+ isLoading = false,
}: {
label: string;
dateStr?: string;
iconSrc: string;
+ isLoading?: boolean;
}) {
return (
-
-
- {dateStr ? fmtShortDate(dateStr) : "-"}
-
- {dateStr && (
-
{fmtRelativeDays(dateStr)}
+
+ {isLoading ? (
+
+ ) : (
+
+ {dateStr ? fmtShortDate(dateStr) : "-"}
+
+ )}
+ {isLoading ? (
+
+ ) : (
+ dateStr && (
+
{fmtRelativeDays(dateStr)}
+ )
)}
@@ -62,9 +73,10 @@ function RewardCard({
export interface PlanSummaryCardProps {
subscription: Subscription | null | undefined;
+ isLoading?: boolean;
}
-export function PlanSummaryCard({ subscription }: PlanSummaryCardProps) {
+export function PlanSummaryCard({ subscription, isLoading = false }: PlanSummaryCardProps) {
return (
@@ -113,10 +125,16 @@ export function PlanSummaryCard({ subscription }: PlanSummaryCardProps) {
className="gap-2! rounded-xl border border-transparent bg-white px-4! shadow-[0px_2px_10px_0px_rgba(0,0,0,0.07)] transition-shadow duration-150 hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.10)] focus-visible:ring-2 focus-visible:ring-[#fa7119]/50 focus-visible:outline-none dark:border-white/6 dark:bg-[#0B0F14] dark:shadow-[0px_2px_10px_0px_rgba(0,0,0,0.3)] dark:hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.4)]"
>
-
- {subscription ? subscription.planName : "No Active Plan"}
-
- {subscription ? (
+ {isLoading ? (
+
+ ) : (
+
+ {subscription ? subscription.planName : "No Active Plan"}
+
+ )}
+ {isLoading ? (
+
+ ) : subscription ? (
{fmtLkrCurrency(subscription.price)}
@@ -134,17 +152,19 @@ export function PlanSummaryCard({ subscription }: PlanSummaryCardProps) {
- {subscription && (
+ {(isLoading || subscription) && (
)}
diff --git a/src/components/dashboard/wallet/TotalValueCard.tsx b/src/components/dashboard/wallet/TotalValueCard.tsx
index 6f3bbfa..d7f912a 100644
--- a/src/components/dashboard/wallet/TotalValueCard.tsx
+++ b/src/components/dashboard/wallet/TotalValueCard.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import { ChevronDown, TrendingUp, TrendingDown } from "lucide-react";
import { Card, Badge } from "@telegram-apps/telegram-ui";
+import { ValueSkeleton } from "@/components/ui/value-skeleton";
import { fmtLkrCurrency, fmtSatsCompact, maskDigits } from "@/lib/formatters";
// Card/Badge theme their background/color through --tgui-- CSS variables (same
@@ -15,6 +16,7 @@ export interface TotalValueCardProps {
changePercent: number;
changeLkr: number;
visible: boolean;
+ isLoading?: boolean;
}
export function TotalValueCard({
@@ -23,6 +25,7 @@ export function TotalValueCard({
changePercent,
changeLkr,
visible,
+ isLoading = false,
}: TotalValueCardProps) {
const mask = (v: string) => maskDigits(v, visible);
const isProfit = changePercent >= 0;
@@ -44,44 +47,56 @@ export function TotalValueCard({
-
- {mask(`≈ ${fmtLkrCurrency(totalLkr)}`)}
-
+ {isLoading ? (
+
+ ) : (
+
+ {mask(`≈ ${fmtLkrCurrency(totalLkr)}`)}
+
+ )}
-
-
-
{mask(`₿ ${(totalSats / 1e8).toFixed(6)}`)}
-
BTC
+ {isLoading ? (
+
+ ) : (
+
+
+ {mask(`₿ ${(totalSats / 1e8).toFixed(6)}`)}
+ BTC
+
+
+
+ {mask(`丰 ${fmtSatsCompact(totalSats)}`)}
+
-
-
- {mask(`丰 ${fmtSatsCompact(totalSats)}`)}
-
-
+ )}
-
-
-
-
- {" "}
- {mask(fmtLkrCurrency(Math.abs(changeLkr)))}
- {` (${isProfit ? "+" : "-"}${Math.abs(changePercent).toFixed(2)}%)`}
- {" Last 24h"}
-
-
-
+ {isLoading ? (
+
+ ) : (
+
+
+
+
+ {" "}
+ {mask(fmtLkrCurrency(Math.abs(changeLkr)))}
+ {` (${isProfit ? "+" : "-"}${Math.abs(changePercent).toFixed(2)}%)`}
+ {" Last 24h"}
+
+
+
+ )}
diff --git a/src/components/ui/value-skeleton.tsx b/src/components/ui/value-skeleton.tsx
new file mode 100644
index 0000000..60135ef
--- /dev/null
+++ b/src/components/ui/value-skeleton.tsx
@@ -0,0 +1,19 @@
+import { cn } from "@/lib/cn";
+
+const TONE_STYLES = {
+ // Light card surfaces (white/#0B0F14 backgrounds).
+ surface: "bg-[#e2e8f0] dark:bg-[#334155]",
+ // Cards rendered over a photo/color background (e.g. hero cards).
+ dark: "bg-white/25 dark:bg-white/15",
+};
+
+export interface ValueSkeletonProps {
+ className?: string;
+ tone?: keyof typeof TONE_STYLES;
+}
+
+export function ValueSkeleton({ className, tone = "surface" }: ValueSkeletonProps) {
+ return (
+
+ );
+}
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts
index 16fbac4..1ad114c 100644
--- a/src/hooks/useUser.ts
+++ b/src/hooks/useUser.ts
@@ -14,7 +14,7 @@ export function useUser() {
const launchParams = useLaunchParams();
const telegramUser = launchParams.initData?.user;
const { isExistingUser } = useStore();
- const { data: subscription } = useSubscriptionCurrent();
+ const { data: subscription, isLoading: subscriptionLoading } = useSubscriptionCurrent();
const { data: kycData } = useKycStatus();
const username = telegramUser?.username;
@@ -32,6 +32,7 @@ export function useUser() {
photoUrl: telegramUser?.photoUrl,
isExistingUser,
subscription: subscription ?? null,
+ subscriptionLoading,
kycStatus: kycData?.status,
};
}
From b12b89d5db726f96e362c784680a0ea55bec84f0 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 17:59:22 +0530
Subject: [PATCH 13/24] feat: update loading state handling in PlansPage and
PlanHeroCard for improved user experience
---
src/app/dashboard/plans/page.tsx | 5 +-
.../dashboard/plans/PlanHeroCard.tsx | 70 +++++++++++++++++--
2 files changed, 67 insertions(+), 8 deletions(-)
diff --git a/src/app/dashboard/plans/page.tsx b/src/app/dashboard/plans/page.tsx
index fe86ceb..b1610fa 100644
--- a/src/app/dashboard/plans/page.tsx
+++ b/src/app/dashboard/plans/page.tsx
@@ -31,7 +31,7 @@ export default function PlansPage() {
if (kycLoading) return ;
if (!kycApproved) return ;
- if (subscriptionLoading || noActivePlan) return ;
+ if (noActivePlan) return ;
const currentValueLkr = summary
? Number(
@@ -52,7 +52,8 @@ export default function PlansPage() {
currentValueLkr={currentValueLkr}
visible={balanceVisible}
onToggleVisible={toggleBalanceVisible}
- isLoading={summaryLoading}
+ subscriptionLoading={subscriptionLoading}
+ summaryLoading={summaryLoading}
/>
diff --git a/src/components/dashboard/plans/PlanHeroCard.tsx b/src/components/dashboard/plans/PlanHeroCard.tsx
index fe1d82d..b4d4ceb 100644
--- a/src/components/dashboard/plans/PlanHeroCard.tsx
+++ b/src/components/dashboard/plans/PlanHeroCard.tsx
@@ -24,7 +24,8 @@ export interface PlanHeroCardProps {
currentValueLkr: number;
visible: boolean;
onToggleVisible: () => void;
- isLoading?: boolean;
+ subscriptionLoading?: boolean;
+ summaryLoading?: boolean;
}
export function PlanHeroCard({
@@ -34,7 +35,8 @@ export function PlanHeroCard({
currentValueLkr,
visible,
onToggleVisible,
- isLoading = false,
+ subscriptionLoading = false,
+ summaryLoading = false,
}: PlanHeroCardProps) {
const router = useRouter();
const mask = (v: string) => maskDigits(v, visible);
@@ -42,6 +44,62 @@ export function PlanHeroCard({
const profitPct = investedLkr > 0 ? (profitLkr / investedLkr) * 100 : 0;
const isProfit = profitLkr >= 0;
+ if (subscriptionLoading) {
+ return (
+
+ );
+ }
+
if (!subscription) {
return (
@@ -178,14 +236,14 @@ export function PlanHeroCard({
You Invested
- {isLoading ? (
+ {summaryLoading ? (
) : (
{mask(fmtLkrCurrency(investedLkr))}
)}
- {isLoading ? (
+ {summaryLoading ? (
) : (
@@ -198,14 +256,14 @@ export function PlanHeroCard({
Current Value
- {isLoading ? (
+ {summaryLoading ? (
) : (
{mask(fmtLkrCurrency(currentValueLkr))}
)}
- {isLoading ? (
+ {summaryLoading ? (
) : (
investedLkr > 0 && (
From 9bae4d9d776774db2f7a9392052ee33877b820b9 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 18:04:45 +0530
Subject: [PATCH 14/24] feat: enhance loading skeletons and styles for improved
user experience across various components
---
src/app/plans/choose/page.tsx | 3 +-
.../dashboard/wallet/PerformanceGrid.tsx | 8 +-
.../dashboard/wallet/PlanSummaryCard.tsx | 194 +++++++++---------
.../dashboard/wallet/TotalValueCard.tsx | 16 +-
src/components/ui/value-skeleton.tsx | 2 +
5 files changed, 110 insertions(+), 113 deletions(-)
diff --git a/src/app/plans/choose/page.tsx b/src/app/plans/choose/page.tsx
index 1d96285..fe4d9b5 100644
--- a/src/app/plans/choose/page.tsx
+++ b/src/app/plans/choose/page.tsx
@@ -19,6 +19,7 @@ import { KycRequiredNotice } from "@/components/dashboard/plans/KycRequiredNotic
import { PageTitle } from "@/components/ui/page-title";
import { TogglePlan, type PlanDuration } from "@/components/ui/toggle-plan";
import { PlanCard } from "@/components/ui/plan-card";
+import { ValueSkeleton } from "@/components/ui/value-skeleton";
import { getPlanIconSrc } from "@/components/dashboard/wallet/PlanSummaryCard";
import { fmtLkrCurrency } from "@/lib/formatters";
import type { SubscriptionPlan } from "@/lib/types";
@@ -201,7 +202,7 @@ export default function ChoosePlanPage() {
{packagesLoading ? (
{[0, 1, 2].map((i) => (
-
+
))}
) : filteredPlans.length === 0 ? (
diff --git a/src/components/dashboard/wallet/PerformanceGrid.tsx b/src/components/dashboard/wallet/PerformanceGrid.tsx
index 6463c63..52526ca 100644
--- a/src/components/dashboard/wallet/PerformanceGrid.tsx
+++ b/src/components/dashboard/wallet/PerformanceGrid.tsx
@@ -15,10 +15,6 @@ const CARD_SURFACE_STYLE = {
"--tgui--tertiary_bg_color": "var(--color-surface-primary)",
} as React.CSSProperties;
-const CARD_SUCCESS_STYLE = {
- "--tgui--tertiary_bg_color": "color-mix(in srgb, var(--color-success) 10%, transparent)",
-} as React.CSSProperties;
-
export interface PerformanceGridProps {
dcaSpent: number;
dcaSats: number;
@@ -88,7 +84,7 @@ export function PerformanceGrid({
@@ -163,7 +159,7 @@ export function PerformanceGrid({
diff --git a/src/components/dashboard/wallet/PlanSummaryCard.tsx b/src/components/dashboard/wallet/PlanSummaryCard.tsx
index 64d0a32..a5baab4 100644
--- a/src/components/dashboard/wallet/PlanSummaryCard.tsx
+++ b/src/components/dashboard/wallet/PlanSummaryCard.tsx
@@ -32,12 +32,10 @@ function RewardCard({
label,
dateStr,
iconSrc,
- isLoading = false,
}: {
label: string;
dateStr?: string;
iconSrc: string;
- isLoading?: boolean;
}) {
return (
-
- {isLoading ? (
-
- ) : (
-
- {dateStr ? fmtShortDate(dateStr) : "-"}
-
- )}
- {isLoading ? (
-
- ) : (
- dateStr && (
-
{fmtRelativeDays(dateStr)}
- )
+
+
+ {dateStr ? fmtShortDate(dateStr) : "-"}
+
+ {dateStr && (
+
{fmtRelativeDays(dateStr)}
)}
@@ -71,6 +61,18 @@ function RewardCard({
);
}
+function PlanSummarySkeleton() {
+ return (
+
+ );
+}
+
export interface PlanSummaryCardProps {
subscription: Subscription | null | undefined;
isLoading?: boolean;
@@ -85,90 +87,92 @@ export function PlanSummaryCard({ subscription, isLoading = false }: PlanSummary
-
-
-
|
- ) : (
-
- )
- }
- after={
-
- {subscription?.isActive && (
-
-
- Active
-
- )}
-
-
- }
- style={{ "--tgui--cell--middle--padding": "12px 0" } as React.CSSProperties}
- className="gap-2! rounded-xl border border-transparent bg-white px-4! shadow-[0px_2px_10px_0px_rgba(0,0,0,0.07)] transition-shadow duration-150 hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.10)] focus-visible:ring-2 focus-visible:ring-[#fa7119]/50 focus-visible:outline-none dark:border-white/6 dark:bg-[#0B0F14] dark:shadow-[0px_2px_10px_0px_rgba(0,0,0,0.3)] dark:hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.4)]"
- >
-
- {isLoading ? (
-
- ) : (
+ {isLoading ? (
+
+ ) : (
+
+
+
|
+ ) : (
+
+ )
+ }
+ after={
+
+ {subscription?.isActive && (
+
+
+ Active
+
+ )}
+
+
+ }
+ style={{ "--tgui--cell--middle--padding": "12px 0" } as React.CSSProperties}
+ className="gap-2! rounded-xl border border-transparent bg-white px-4! shadow-[0px_2px_10px_0px_rgba(0,0,0,0.07)] transition-shadow duration-150 hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.10)] focus-visible:ring-2 focus-visible:ring-[#fa7119]/50 focus-visible:outline-none dark:border-white/6 dark:bg-[#0B0F14] dark:shadow-[0px_2px_10px_0px_rgba(0,0,0,0.3)] dark:hover:shadow-[0px_4px_14px_0px_rgba(0,0,0,0.4)]"
+ >
+
{subscription ? subscription.planName : "No Active Plan"}
- )}
- {isLoading ? (
-
- ) : subscription ? (
-
-
- {fmtLkrCurrency(subscription.price)}
+ {subscription ? (
+
+
+ {fmtLkrCurrency(subscription.price)}
+
+
+ /{subscription.planType === "weekly" ? "week" : "month"}
+
+
+ ) : (
+
+ Choose a plan to start earning rewards
-
- /{subscription.planType === "weekly" ? "week" : "month"}
-
-
- ) : (
-
- Choose a plan to start earning rewards
-
- )}
-
-
-
+ )}
+
+
+
- {(isLoading || subscription) && (
-
-
-
-
- )}
-
+ {subscription && (
+
+
+
+
+ )}
+
+ )}
);
}
diff --git a/src/components/dashboard/wallet/TotalValueCard.tsx b/src/components/dashboard/wallet/TotalValueCard.tsx
index d7f912a..01da0e0 100644
--- a/src/components/dashboard/wallet/TotalValueCard.tsx
+++ b/src/components/dashboard/wallet/TotalValueCard.tsx
@@ -48,17 +48,15 @@ export function TotalValueCard({
{isLoading ? (
-
+
) : (
{mask(`≈ ${fmtLkrCurrency(totalLkr)}`)}
)}
-
- {isLoading ? (
-
- ) : (
+ {!isLoading && (
+
{mask(`₿ ${(totalSats / 1e8).toFixed(6)}`)}
@@ -69,11 +67,7 @@ export function TotalValueCard({
{mask(`丰 ${fmtSatsCompact(totalSats)}`)}
- )}
- {isLoading ? (
-
- ) : (
- )}
-
+
+ )}
);
diff --git a/src/components/ui/value-skeleton.tsx b/src/components/ui/value-skeleton.tsx
index 60135ef..acd7b92 100644
--- a/src/components/ui/value-skeleton.tsx
+++ b/src/components/ui/value-skeleton.tsx
@@ -5,6 +5,8 @@ const TONE_STYLES = {
surface: "bg-[#e2e8f0] dark:bg-[#334155]",
// Cards rendered over a photo/color background (e.g. hero cards).
dark: "bg-white/25 dark:bg-white/15",
+ // Subtler variant for prominent hero values — a faint tint rather than a solid block.
+ translucent: "bg-white/10",
};
export interface ValueSkeletonProps {
From 256854288f05944cba0796de42a2cd1fabc22cfc Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 18:07:54 +0530
Subject: [PATCH 15/24] feat: update background opacity in PlanHeroCard and
TotalValueCard for improved visual consistency
---
src/components/dashboard/plans/PlanHeroCard.tsx | 4 ++--
src/components/dashboard/wallet/TotalValueCard.tsx | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/dashboard/plans/PlanHeroCard.tsx b/src/components/dashboard/plans/PlanHeroCard.tsx
index b4d4ceb..2d152c0 100644
--- a/src/components/dashboard/plans/PlanHeroCard.tsx
+++ b/src/components/dashboard/plans/PlanHeroCard.tsx
@@ -60,7 +60,7 @@ export function PlanHeroCard({
style={{ "--tgui--tertiary_bg_color": "transparent" } as React.CSSProperties}
>
-
+
@@ -145,7 +145,7 @@ export function PlanHeroCard({
style={{ "--tgui--tertiary_bg_color": "transparent" } as React.CSSProperties}
>
-
+
diff --git a/src/components/dashboard/wallet/TotalValueCard.tsx b/src/components/dashboard/wallet/TotalValueCard.tsx
index 01da0e0..2da749d 100644
--- a/src/components/dashboard/wallet/TotalValueCard.tsx
+++ b/src/components/dashboard/wallet/TotalValueCard.tsx
@@ -37,7 +37,7 @@ export function TotalValueCard({
style={{ "--tgui--tertiary_bg_color": "transparent" } as React.CSSProperties}
>
-
+
Total Value
From 8faed26d541868a5da1462ba40f068b15bc60d79 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 18:07:58 +0530
Subject: [PATCH 16/24] feat: conditionally render period switcher based on
available data points for improved user experience
---
.../dashboard/wallet/PortfolioChart.tsx | 61 +++++++++++--------
1 file changed, 35 insertions(+), 26 deletions(-)
diff --git a/src/components/dashboard/wallet/PortfolioChart.tsx b/src/components/dashboard/wallet/PortfolioChart.tsx
index badf822..4b0aed5 100644
--- a/src/components/dashboard/wallet/PortfolioChart.tsx
+++ b/src/components/dashboard/wallet/PortfolioChart.tsx
@@ -85,7 +85,14 @@ export function PortfolioChart({ transactions, currentBtcPrice }: PortfolioChart
() => buildPoints(transactions, currentBtcPrice),
[transactions, currentBtcPrice]
);
- const points = useMemo(() => filterByPeriod(allPoints, chartPeriod), [allPoints, chartPeriod]);
+ // With fewer than 2 points every period falls back to the same full set (see
+ // filterByPeriod), so the switcher would just be dead buttons — hide it and
+ // render the one timeframe that's actually meaningful.
+ const showPeriodSwitcher = allPoints.length >= 2;
+ const points = useMemo(
+ () => (showPeriodSwitcher ? filterByPeriod(allPoints, chartPeriod) : allPoints),
+ [allPoints, chartPeriod, showPeriodSwitcher]
+ );
const labels = points.map((p) =>
new Date(p.date).toLocaleDateString("en-GB", { day: "2-digit", month: "short" })
@@ -207,31 +214,33 @@ export function PortfolioChart({ transactions, currentBtcPrice }: PortfolioChart
} as React.CSSProperties
}
>
-
- {PERIODS.map((p) => (
- handlePeriodClick(p)}
- style={
- {
- "--tgui--button--hovered-opacity": 0,
- backgroundColor:
- selectedPeriod === p ? "#1f2a36" : isDark ? "#1e293b" : "#eeeff3",
- color: selectedPeriod === p ? "#fff" : isDark ? "#94a3b8" : "#64748b",
- } as React.CSSProperties
- }
- // tgui's Button renders a square `:after` hover overlay that isn't clipped to
- // its own border-radius, so its corners poke out past the pill on mouse hover
- // unless the button itself clips overflow.
- className="overflow-hidden! rounded-[12px]!"
- >
- {p}
-
- ))}
-
+ {showPeriodSwitcher && (
+
+ {PERIODS.map((p) => (
+ handlePeriodClick(p)}
+ style={
+ {
+ "--tgui--button--hovered-opacity": 0,
+ backgroundColor:
+ selectedPeriod === p ? "#1f2a36" : isDark ? "#1e293b" : "#eeeff3",
+ color: selectedPeriod === p ? "#fff" : isDark ? "#94a3b8" : "#64748b",
+ } as React.CSSProperties
+ }
+ // tgui's Button renders a square `:after` hover overlay that isn't clipped to
+ // its own border-radius, so its corners poke out past the pill on mouse hover
+ // unless the button itself clips overflow.
+ className="overflow-hidden! rounded-[12px]!"
+ >
+ {p}
+
+ ))}
+
+ )}
{points.length === 0 ? (
From 4b47bc5305dbd0b88aed31077ab6cf315dc216b3 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 18:39:54 +0530
Subject: [PATCH 17/24] feat: comment out unused imports and functions in
PortfolioChart for cleaner code
---
.../dashboard/wallet/PortfolioChart.tsx | 126 +++++++++---------
1 file changed, 64 insertions(+), 62 deletions(-)
diff --git a/src/components/dashboard/wallet/PortfolioChart.tsx b/src/components/dashboard/wallet/PortfolioChart.tsx
index 4b0aed5..8528869 100644
--- a/src/components/dashboard/wallet/PortfolioChart.tsx
+++ b/src/components/dashboard/wallet/PortfolioChart.tsx
@@ -1,6 +1,7 @@
"use client";
-import { useMemo, useState, useTransition } from "react";
+import { useMemo } from "react";
+// import { useMemo, useState, useTransition } from "react";
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
@@ -12,23 +13,26 @@ import {
Filler,
type TooltipItem,
} from "chart.js";
-import { Card, Button } from "@telegram-apps/telegram-ui";
-import { cn } from "@/lib/cn";
+import { Card } from "@telegram-apps/telegram-ui";
+// import { Card, Button } from "@telegram-apps/telegram-ui";
+// import { cn } from "@/lib/cn";
import { fmtLkrCurrency, fmtPriceCompact } from "@/lib/formatters";
import { useTheme } from "@/app/context/theme";
import type { DcaTransaction } from "@/hooks/query/useTransactionHistory";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Filler);
-const PERIODS = ["1M", "3M", "6M", "1Y", "All"] as const;
-type Period = (typeof PERIODS)[number];
+// Period switcher is disabled for now — chart always renders the full "All" range.
+// Kept here (commented) so it can be re-enabled without reconstructing the logic.
+// const PERIODS = ["1M", "3M", "6M", "1Y", "All"] as const;
+// type Period = (typeof PERIODS)[number];
-const PERIOD_MONTHS: Record, number> = {
- "1M": 1,
- "3M": 3,
- "6M": 6,
- "1Y": 12,
-};
+// const PERIOD_MONTHS: Record, number> = {
+// "1M": 1,
+// "3M": 3,
+// "6M": 6,
+// "1Y": 12,
+// };
interface ChartPoint {
date: string;
@@ -53,14 +57,14 @@ function buildPoints(transactions: DcaTransaction[], currentBtcPrice: number): C
});
}
-function filterByPeriod(points: ChartPoint[], period: Period): ChartPoint[] {
- if (period === "All") return points;
- const months = PERIOD_MONTHS[period];
- const cutoff = new Date();
- cutoff.setMonth(cutoff.getMonth() - months);
- const windowed = points.filter((p) => new Date(p.date) >= cutoff);
- return windowed.length >= 2 ? windowed : points;
-}
+// function filterByPeriod(points: ChartPoint[], period: Period): ChartPoint[] {
+// if (period === "All") return points;
+// const months = PERIOD_MONTHS[period];
+// const cutoff = new Date();
+// cutoff.setMonth(cutoff.getMonth() - months);
+// const windowed = points.filter((p) => new Date(p.date) >= cutoff);
+// return windowed.length >= 2 ? windowed : points;
+// }
export interface PortfolioChartProps {
transactions: DcaTransaction[];
@@ -72,27 +76,22 @@ export function PortfolioChart({ transactions, currentBtcPrice }: PortfolioChart
// The pill highlight (`selectedPeriod`) updates immediately on click; the chart-driving
// `chartPeriod` updates inside a transition so filtering/re-rendering the chart doesn't
// delay the button's own paint.
- const [selectedPeriod, setSelectedPeriod] = useState("3M");
- const [chartPeriod, setChartPeriod] = useState("3M");
- const [isPending, startTransition] = useTransition();
+ // const [selectedPeriod, setSelectedPeriod] = useState("3M");
+ // const [chartPeriod, setChartPeriod] = useState("3M");
+ // const [isPending, startTransition] = useTransition();
- const handlePeriodClick = (p: Period) => {
- setSelectedPeriod(p);
- startTransition(() => setChartPeriod(p));
- };
+ // const handlePeriodClick = (p: Period) => {
+ // setSelectedPeriod(p);
+ // startTransition(() => setChartPeriod(p));
+ // };
const allPoints = useMemo(
() => buildPoints(transactions, currentBtcPrice),
[transactions, currentBtcPrice]
);
- // With fewer than 2 points every period falls back to the same full set (see
- // filterByPeriod), so the switcher would just be dead buttons — hide it and
- // render the one timeframe that's actually meaningful.
- const showPeriodSwitcher = allPoints.length >= 2;
- const points = useMemo(
- () => (showPeriodSwitcher ? filterByPeriod(allPoints, chartPeriod) : allPoints),
- [allPoints, chartPeriod, showPeriodSwitcher]
- );
+ // Always render the full "All" range — see PERIODS comment above.
+ const points = allPoints;
+ // const points = useMemo(() => filterByPeriod(allPoints, chartPeriod), [allPoints, chartPeriod]);
const labels = points.map((p) =>
new Date(p.date).toLocaleDateString("en-GB", { day: "2-digit", month: "short" })
@@ -214,33 +213,33 @@ export function PortfolioChart({ transactions, currentBtcPrice }: PortfolioChart
} as React.CSSProperties
}
>
- {showPeriodSwitcher && (
-
- {PERIODS.map((p) => (
- handlePeriodClick(p)}
- style={
- {
- "--tgui--button--hovered-opacity": 0,
- backgroundColor:
- selectedPeriod === p ? "#1f2a36" : isDark ? "#1e293b" : "#eeeff3",
- color: selectedPeriod === p ? "#fff" : isDark ? "#94a3b8" : "#64748b",
- } as React.CSSProperties
- }
- // tgui's Button renders a square `:after` hover overlay that isn't clipped to
- // its own border-radius, so its corners poke out past the pill on mouse hover
- // unless the button itself clips overflow.
- className="overflow-hidden! rounded-[12px]!"
- >
- {p}
-
- ))}
-
- )}
+ {/* Period switcher — disabled, see PERIODS comment above.
+
+ {PERIODS.map((p) => (
+ handlePeriodClick(p)}
+ style={
+ {
+ "--tgui--button--hovered-opacity": 0,
+ backgroundColor:
+ selectedPeriod === p ? "#1f2a36" : isDark ? "#1e293b" : "#eeeff3",
+ color: selectedPeriod === p ? "#fff" : isDark ? "#94a3b8" : "#64748b",
+ } as React.CSSProperties
+ }
+ // tgui's Button renders a square `:after` hover overlay that isn't clipped to
+ // its own border-radius, so its corners poke out past the pill on mouse hover
+ // unless the button itself clips overflow.
+ className="overflow-hidden! rounded-[12px]!"
+ >
+ {p}
+
+ ))}
+
+ */}
{points.length === 0 ? (
@@ -251,9 +250,12 @@ export function PortfolioChart({ transactions, currentBtcPrice }: PortfolioChart
) : (
-
+
+ //
+ //
+ //
)}
From 59ac850160bec795c5dc674f769b45e5fc2e5212 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 18:47:59 +0530
Subject: [PATCH 18/24] feat: refactor TotalValueCard to improve layout and
visibility of badge component
---
.../dashboard/wallet/TotalValueCard.tsx | 69 +++++++++----------
1 file changed, 34 insertions(+), 35 deletions(-)
diff --git a/src/components/dashboard/wallet/TotalValueCard.tsx b/src/components/dashboard/wallet/TotalValueCard.tsx
index 2da749d..cf1d326 100644
--- a/src/components/dashboard/wallet/TotalValueCard.tsx
+++ b/src/components/dashboard/wallet/TotalValueCard.tsx
@@ -4,6 +4,7 @@ import Image from "next/image";
import { ChevronDown, TrendingUp, TrendingDown } from "lucide-react";
import { Card, Badge } from "@telegram-apps/telegram-ui";
import { ValueSkeleton } from "@/components/ui/value-skeleton";
+import { cn } from "@/lib/cn";
import { fmtLkrCurrency, fmtSatsCompact, maskDigits } from "@/lib/formatters";
// Card/Badge theme their background/color through --tgui-- CSS variables (same
@@ -55,43 +56,41 @@ export function TotalValueCard({
)}
- {!isLoading && (
-
-
-
- {mask(`₿ ${(totalSats / 1e8).toFixed(6)}`)}
- BTC
-
-
-
- {mask(`丰 ${fmtSatsCompact(totalSats)}`)}
-
+
+
+
+ {mask(`₿ ${(totalSats / 1e8).toFixed(6)}`)}
+ BTC
+
+
+
+ {mask(`丰 ${fmtSatsCompact(totalSats)}`)}
-
-
-
-
-
- {" "}
- {mask(fmtLkrCurrency(Math.abs(changeLkr)))}
- {` (${isProfit ? "+" : "-"}${Math.abs(changePercent).toFixed(2)}%)`}
- {" Last 24h"}
-
-
-
- )}
+
+
+
+
+
+ {" "}
+ {mask(fmtLkrCurrency(Math.abs(changeLkr)))}
+ {` (${isProfit ? "+" : "-"}${Math.abs(changePercent).toFixed(2)}%)`}
+ {" Last 24h"}
+
+
+
+
);
From 6e24eb9004cd0d4c60d23ee2e81a06eb1aded40b Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 19:44:47 +0530
Subject: [PATCH 19/24] feat: refactor useTransactionHistory to support
pagination and improve transaction data handling
---
src/app/dashboard/activity/page.tsx | 36 +++++++----
src/app/dashboard/page.tsx | 2 +-
src/hooks/query/useTransactionHistory.ts | 77 +++++++++++++++++-------
3 files changed, 78 insertions(+), 37 deletions(-)
diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx
index f56710f..e24397e 100644
--- a/src/app/dashboard/activity/page.tsx
+++ b/src/app/dashboard/activity/page.tsx
@@ -24,11 +24,9 @@ export default function ActivityPage() {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
- const { data: transactions, isLoading } = useTransactionHistory();
- const activity = useMemo(
- () => (transactions ?? []).map(mapDcaTransactionToActivityItem),
- [transactions]
- );
+ const { transactions, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
+ useTransactionHistory();
+ const activity = useMemo(() => transactions.map(mapDcaTransactionToActivityItem), [transactions]);
const comingSoon = COMING_SOON_CATEGORIES.includes(category);
@@ -80,14 +78,26 @@ export default function ActivityPage() {
) : groups.length === 0 ? (
No activity found.
) : (
- groups.map(([dayKey, items]) => (
-
- ))
+ <>
+ {groups.map(([dayKey, items]) => (
+
+ ))}
+
+ {hasNextPage && (
+ fetchNextPage()}
+ disabled={isFetchingNextPage}
+ className="py-3 text-center text-[14px] font-semibold text-[#fa7119] disabled:opacity-50"
+ >
+ {isFetchingNextPage ? "Loading..." : "Load more"}
+
+ )}
+ >
)}
);
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index e673927..7eb30b7 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -15,7 +15,7 @@ export default function WalletPage() {
const { subscription, subscriptionLoading } = useUser();
const { data: summary, isLoading: isSummaryLoading } = useWalletSummary();
- const { data: transactions } = useTransactionHistory();
+ const { transactions } = useTransactionHistory();
const totalLkr = summary
? Number(
diff --git a/src/hooks/query/useTransactionHistory.ts b/src/hooks/query/useTransactionHistory.ts
index a58e6cc..dffcd4d 100644
--- a/src/hooks/query/useTransactionHistory.ts
+++ b/src/hooks/query/useTransactionHistory.ts
@@ -1,6 +1,7 @@
"use client";
-import { useQuery } from "@tanstack/react-query";
+import { useMemo } from "react";
+import { useInfiniteQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { getAuthTokenFromStorage } from "@/lib/auth";
import fetchy from "@/lib/fetchy";
@@ -29,46 +30,76 @@ interface RawTransaction {
settled?: boolean;
}
+interface PaginatedTransactions {
+ transactions: RawTransaction[];
+ has_more?: boolean;
+}
+
interface TransactionListResponse {
- transactions: RawTransaction[] | { transactions: RawTransaction[] };
+ transactions: RawTransaction[] | PaginatedTransactions;
}
+const PAGE_SIZE = 20;
+
function toNumber(value: string | number | undefined | null): number {
if (value === undefined || value === null) return 0;
return typeof value === "string" ? Number(value.replace(/,/g, "")) || 0 : value;
}
+function normalize(tx: RawTransaction): DcaTransaction {
+ return {
+ id: tx.payhere_pay_id,
+ created_at: tx.created_at,
+ satoshis_purchased: toNumber(tx.satoshis_purchased),
+ btc_price_at_purchase: toNumber(tx.btc_price_at_purchase),
+ package_amount: toNumber(tx.package_amount),
+ gross_amount: toNumber(tx.gross_amount),
+ package_name: tx.package_name,
+ status: tx.status,
+ settled: tx.settled,
+ };
+}
+
// All DCA purchase attempts (success, pending, cancelled, failed, chargeback),
-// sorted oldest-first, with numeric fields normalized.
+// paginated oldest page first via `has_more`, sorted oldest-first within the
+// flattened result with numeric fields normalized.
export function useTransactionHistory() {
const authToken = getAuthTokenFromStorage();
- return useQuery({
+ const query = useInfiniteQuery({
queryKey: queryKeys.transactions,
- queryFn: async () => {
- const data = await fetchy.get("/api/transaction/list?limit=100", {
- headers: { Authorization: `Bearer ${authToken}` },
- shouldCache: false,
- });
+ queryFn: async ({ pageParam }) => {
+ const data = await fetchy.get(
+ `/api/transaction/list?page=${pageParam}&limit=${PAGE_SIZE}`,
+ {
+ headers: { Authorization: `Bearer ${authToken}` },
+ shouldCache: false,
+ }
+ );
+
+ const nested = Array.isArray(data.transactions) ? null : data.transactions;
const raw = Array.isArray(data.transactions)
? data.transactions
- : (data.transactions?.transactions ?? []);
+ : (nested?.transactions ?? []);
- return raw
- .map((tx) => ({
- id: tx.payhere_pay_id,
- created_at: tx.created_at,
- satoshis_purchased: toNumber(tx.satoshis_purchased),
- btc_price_at_purchase: toNumber(tx.btc_price_at_purchase),
- package_amount: toNumber(tx.package_amount),
- gross_amount: toNumber(tx.gross_amount),
- package_name: tx.package_name,
- status: tx.status,
- settled: tx.settled,
- }))
- .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime());
+ return {
+ transactions: raw.map(normalize),
+ hasMore: nested?.has_more ?? raw.length === PAGE_SIZE,
+ };
},
+ initialPageParam: 1,
+ getNextPageParam: (lastPage, allPages) => (lastPage.hasMore ? allPages.length + 1 : undefined),
enabled: !!authToken,
staleTime: 1000 * 60 * 5,
});
+
+ const transactions = useMemo(
+ () =>
+ (query.data?.pages ?? [])
+ .flatMap((page) => page.transactions)
+ .sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
+ [query.data]
+ );
+
+ return { ...query, transactions };
}
From f52f033fe2369c016bacaae33ad4a01af60040f0 Mon Sep 17 00:00:00 2001
From: rayaanr
Date: Tue, 4 Aug 2026 19:46:54 +0530
Subject: [PATCH 20/24] feat: update totalLkr calculation to use
currentValueLkr for accurate performance metrics
---
src/app/dashboard/page.tsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index 7eb30b7..56b96e8 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -40,6 +40,7 @@ export default function WalletPage() {
currentBtcPriceUsd && currentBtcPrice
? avgBtcPrice * (currentBtcPriceUsd / currentBtcPrice)
: undefined;
+ const currentValueLkr = (dcaSats / 1e8) * currentBtcPrice;
return (
@@ -59,7 +60,7 @@ export default function WalletPage() {
Date: Tue, 4 Aug 2026 20:21:18 +0530
Subject: [PATCH 21/24] feat: reveal plan details only after selection for
improved user interaction
---
src/app/plans/choose/page.tsx | 5 ++---
src/components/ui/plan-card.tsx | 36 +++++++++++++++++----------------
2 files changed, 21 insertions(+), 20 deletions(-)
diff --git a/src/app/plans/choose/page.tsx b/src/app/plans/choose/page.tsx
index fe4d9b5..2992bab 100644
--- a/src/app/plans/choose/page.tsx
+++ b/src/app/plans/choose/page.tsx
@@ -69,9 +69,8 @@ export default function ChoosePlanPage() {
[packages, duration, currentPlan]
);
- // Derived rather than effect-driven: falls back to the first plan of the
- // active duration until the user makes an explicit selection.
- const selectedPlanId = selectedId ?? filteredPlans[0]?.id;
+ // Only set once the user explicitly taps a plan — details stay hidden until then.
+ const selectedPlanId = selectedId;
const handleDurationChange = (d: PlanDuration) => {
haptic.select();
diff --git a/src/components/ui/plan-card.tsx b/src/components/ui/plan-card.tsx
index cb425e5..4bc6026 100644
--- a/src/components/ui/plan-card.tsx
+++ b/src/components/ui/plan-card.tsx
@@ -119,25 +119,27 @@ export function PlanCard({
- {/* Bottom: description + pricing */}
-
-
- {description}
-
-
- {perMonth && (
- <>
-
- {perMonth}
-
-
- >
- )}
-
- {perYear}
+ {/* Bottom: description + pricing, revealed only once the plan is selected */}
+ {isHighlighted && (
+
+
+ {description}
+
+ {perMonth && (
+ <>
+
+ {perMonth}
+
+
+ >
+ )}
+
+ {perYear}
+
+
-
+ )}
);
From edbad53e5fce33c20cf43f7f7fa6bb4a7beb618d Mon Sep 17 00:00:00 2001
From: Dilshan Madushanka
Date: Tue, 4 Aug 2026 22:17:48 +0530
Subject: [PATCH 22/24] =?UTF-8?q?feat:=20implement=20Telegram=20authentica?=
=?UTF-8?q?tion=20flow=20with=20widget=20support=20for=20=E2=80=A6=20(#83)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: implement Telegram authentication flow with widget support for browser sign-in
* feat: refactor Telegram platform hooks to use non-throwing functions for improved error handling
* feat: implement Telegram OIDC authentication flow and update related components
* feat: implement redirection for non-Telegram users after browser authentication and remove BrowserAuthenticatedScreen component
* feat: enhance user data handling by integrating first and last name in useUser hook and updating verification components
* feat: refactor DashboardTopBar to improve layout and integrate logout functionality for non-Telegram users
---
.env | 3 +
src/app/api/auth/telegram-oidc/route.ts | 27 ++++++
src/app/context/tma.tsx | 20 ++++-
src/app/page.tsx | 38 +++++++-
src/app/plans/choose/page.tsx | 29 +++---
src/app/verification/details/page.tsx | 13 ++-
src/app/verification/page.tsx | 7 +-
src/components/auth/BrowserLoginScreen.tsx | 89 +++++++++++++++++++
src/components/auth/TelegramLoginButton.tsx | 54 +++++++++++
.../dashboard/wallet/DashboardTopBar.tsx | 35 ++++++--
src/hooks/useIsTelegramEnv.ts | 46 ++++++++++
src/hooks/useTelegramPlatform.ts | 20 ++++-
src/hooks/useTgBackButton.ts | 13 ++-
src/hooks/useUser.ts | 70 ++++++++++++---
src/lib/auth.ts | 81 +++++++++++++++++
src/lib/constants.ts | 4 +
16 files changed, 498 insertions(+), 51 deletions(-)
create mode 100644 src/app/api/auth/telegram-oidc/route.ts
create mode 100644 src/components/auth/BrowserLoginScreen.tsx
create mode 100644 src/components/auth/TelegramLoginButton.tsx
create mode 100644 src/hooks/useIsTelegramEnv.ts
diff --git a/.env b/.env
index 39646a7..0ddf803 100644
--- a/.env
+++ b/.env
@@ -4,5 +4,8 @@ API_BASE_URL=http://localhost:3000
# Public Telegram bot username (no @, used to build t.me links client-side)
NEXT_PUBLIC_TELEGRAM_BOT_USERNAME=BitcoinDeepaBot
+# Telegram Login (OIDC) Client ID from @BotFather -> bot -> Login Widget (public, not secret)
+NEXT_PUBLIC_TELEGRAM_CLIENT_ID=
+
# MongoDB connection string
MONGODB_URI=your_mongodb_connection_string_here
diff --git a/src/app/api/auth/telegram-oidc/route.ts b/src/app/api/auth/telegram-oidc/route.ts
new file mode 100644
index 0000000..7c63b43
--- /dev/null
+++ b/src/app/api/auth/telegram-oidc/route.ts
@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from "next/server";
+
+/**
+ * Proxies a Telegram Login (OIDC) id_token to the backend for verification.
+ * Unlike /api/auth/telegram, this has no dev-mode fallback: verifying the
+ * JWT's signature requires fetching Telegram's JWKS, which only makes sense
+ * to do once, on the backend.
+ */
+export async function POST(request: NextRequest) {
+ try {
+ const body = await request.json();
+
+ const response = await fetch(`${process.env.API_BASE_URL}/auth/telegram-oidc`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(body),
+ });
+
+ const data = await response.json();
+ return NextResponse.json(data, { status: response.status });
+ } catch (error) {
+ console.error("Error in telegram OIDC auth:", error);
+ return NextResponse.json({ error: "Failed to authenticate with Telegram" }, { status: 500 });
+ }
+}
diff --git a/src/app/context/tma.tsx b/src/app/context/tma.tsx
index b604e8a..508e32a 100644
--- a/src/app/context/tma.tsx
+++ b/src/app/context/tma.tsx
@@ -3,8 +3,19 @@
import { useEffect } from "react";
import { initMiniApp, postEvent } from "@telegram-apps/sdk-react";
import { useRegisterUser } from "@/hooks/useRegisterUser";
+import { useIsTelegramEnv } from "@/hooks/useIsTelegramEnv";
+
+// useRegisterUser calls the Telegram SDK's useLaunchParams(), which throws
+// outside Telegram — only mount it once we've confirmed we're in Telegram,
+// via a separate component so the hook is never called conditionally.
+function RegisterUserGate() {
+ useRegisterUser();
+ return null;
+}
export default function TMASetupProvider({ children }: { children: React.ReactNode }) {
+ const isTelegramEnv = useIsTelegramEnv();
+
useEffect(() => {
try {
const [miniApp] = initMiniApp();
@@ -15,7 +26,10 @@ export default function TMASetupProvider({ children }: { children: React.ReactNo
}
}, []);
- useRegisterUser();
-
- return <>{children}>;
+ return (
+ <>
+ {isTelegramEnv && }
+ {children}
+ >
+ );
}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index c1ce326..b576e1c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -3,14 +3,17 @@
import { useEffect, useMemo, useState } from "react";
import Image from "next/image";
import Link from "next/link";
+import { useRouter } from "next/navigation";
import { useInitData, useLaunchParams } from "@telegram-apps/sdk-react";
import { Badge, Button, Cell, Navigation, Progress, Title } from "@telegram-apps/telegram-ui";
import { useTheme } from "@/app/context/theme";
import { useStore } from "@/lib/store";
-import { getAuthTokenFromStorage, getIsExistingUserFromStorage } from "@/lib/auth";
+import { getAuthTokenFromStorage, getIsExistingUserFromStorage, isAuthenticated } from "@/lib/auth";
import { TELEGRAM_BOT_URL, TELEGRAM_BOT_USERNAME } from "@/lib/constants";
import { useRegisterTelegramUser } from "@/hooks/query/useRegisterTelegramUser";
import { useUserCount } from "@/hooks/query/useUserCount";
+import { useIsTelegramEnv } from "@/hooks/useIsTelegramEnv";
+import BrowserLoginScreen from "@/components/auth/BrowserLoginScreen";
// ─── Shared progress bar ────────────────────────────────────────────────────
@@ -156,7 +159,9 @@ function UserScreen({ isExisting }: { isExisting: boolean }) {
);
}
-export default function Home() {
+// Uses Telegram SDK hooks that throw outside the Telegram WebView — only
+// mount this once we've confirmed we're running inside Telegram.
+function TelegramHome() {
const initLaunchParams = useLaunchParams().initData;
const launchParams = useLaunchParams();
const initData = useInitData();
@@ -187,3 +192,32 @@ export default function Home() {
);
}
+
+export default function Home() {
+ const router = useRouter();
+ const isTelegramEnv = useIsTelegramEnv();
+ const [isBrowserAuthed, setIsBrowserAuthed] = useState(() => isAuthenticated());
+
+ useEffect(() => {
+ if (isTelegramEnv === false && isBrowserAuthed) {
+ router.replace("/dashboard?tab=wallet");
+ }
+ }, [isTelegramEnv, isBrowserAuthed, router]);
+
+ // Still running the non-throwing isTMA() check — avoid flashing either UI.
+ if (isTelegramEnv === null) {
+ return {null} ;
+ }
+
+ if (isTelegramEnv === false) {
+ // Already authenticated — redirecting into /dashboard above; render
+ // nothing in the meantime instead of flashing the login screen.
+ return isBrowserAuthed ? (
+ {null}
+ ) : (
+ setIsBrowserAuthed(true)} />
+ );
+ }
+
+ return ;
+}
diff --git a/src/app/plans/choose/page.tsx b/src/app/plans/choose/page.tsx
index 2992bab..6a5100f 100644
--- a/src/app/plans/choose/page.tsx
+++ b/src/app/plans/choose/page.tsx
@@ -37,7 +37,6 @@ export default function ChoosePlanPage() {
const redirectToPayHereViaPage = usePayHereRedirect();
const authToken = getAuthTokenFromStorage();
const { subscription } = useUser();
- const popup = initPopup();
const cancelSubscription = useCancelSubscription();
const [duration, setDuration] = useState("weekly");
@@ -117,15 +116,25 @@ export default function ChoosePlanPage() {
};
const handleCancel = async () => {
- const buttonId = await popup.open({
- title: "Cancel Plan",
- message: "Your membership rewards will stop accruing immediately. This can't be undone.",
- buttons: [
- { id: "cancel", type: "destructive", text: "Cancel Plan" },
- { id: "keep", type: "cancel" },
- ],
- });
- if (buttonId !== "cancel") return;
+ // initPopup() throws outside Telegram (no native popup API there) — fall
+ // back to the browser's own confirm dialog in that case.
+ let confirmed: boolean;
+ try {
+ const buttonId = await initPopup().open({
+ title: "Cancel Plan",
+ message: "Your membership rewards will stop accruing immediately. This can't be undone.",
+ buttons: [
+ { id: "cancel", type: "destructive", text: "Cancel Plan" },
+ { id: "keep", type: "cancel" },
+ ],
+ });
+ confirmed = buttonId === "cancel";
+ } catch {
+ confirmed = window.confirm(
+ "Cancel Plan? Your membership rewards will stop accruing immediately. This can't be undone."
+ );
+ }
+ if (!confirmed) return;
haptic.impact("rigid");
diff --git a/src/app/verification/details/page.tsx b/src/app/verification/details/page.tsx
index 2985dc9..be6341a 100644
--- a/src/app/verification/details/page.tsx
+++ b/src/app/verification/details/page.tsx
@@ -1,12 +1,12 @@
"use client";
import { useRouter } from "next/navigation";
-import { useLaunchParams } from "@telegram-apps/sdk-react";
import { Button, Input } from "@telegram-apps/telegram-ui";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useKycInitiate, useUpdateProfile } from "@/hooks/query/useKyc";
import { useTelegramBackButton } from "@/hooks/useTgBackButton";
+import { useUser } from "@/hooks/useUser";
import { haptic } from "@/lib/haptics";
import { createUserSchema, type CreateUserFormData } from "@/lib/validations";
@@ -27,8 +27,7 @@ const FIELDS: Array<{
export default function VerificationDetailsPage() {
const router = useRouter();
- const launchParams = useLaunchParams();
- const userData = launchParams.initData?.user;
+ const { id: userId, username, firstName, lastName } = useUser();
const kycInitiate = useKycInitiate();
const updateProfile = useUpdateProfile();
@@ -40,8 +39,8 @@ export default function VerificationDetailsPage() {
resolver: zodResolver(createUserSchema),
mode: "onChange",
defaultValues: {
- first_name: userData?.firstName ?? "",
- last_name: userData?.lastName ?? "",
+ first_name: firstName ?? "",
+ last_name: lastName ?? "",
email: "",
phone: "",
address: "",
@@ -75,8 +74,8 @@ export default function VerificationDetailsPage() {
onSuccess: () => {
kycInitiate.mutate(
{
- user_id: userData?.id,
- username: userData?.username,
+ user_id: userId ? Number(userId) : undefined,
+ username,
...formData,
},
{
diff --git a/src/app/verification/page.tsx b/src/app/verification/page.tsx
index e3004ff..5ea0722 100644
--- a/src/app/verification/page.tsx
+++ b/src/app/verification/page.tsx
@@ -3,12 +3,12 @@
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
-import { useLaunchParams } from "@telegram-apps/sdk-react";
import { Button, Modal } from "@telegram-apps/telegram-ui";
import { X } from "lucide-react";
import { Drawer } from "@xelene/vaul-with-scroll-fix";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTelegramPlatform } from "@/hooks/useTelegramPlatform";
+import { useUser } from "@/hooks/useUser";
import { useKycStatus } from "@/hooks/query/useKyc";
import { useTelegramBackButton } from "@/hooks/useTgBackButton";
import { haptic } from "@/lib/haptics";
@@ -95,8 +95,7 @@ const SHOW_STEPS_FOR: VerificationStatus[] = [
export default function VerificationIntroPage() {
const router = useRouter();
- const launchParams = useLaunchParams();
- const userData = launchParams.initData?.user;
+ const { id: userId } = useUser();
const { isMobile } = useTelegramPlatform();
const [showMobileWarning, setShowMobileWarning] = useState(false);
@@ -276,7 +275,7 @@ export default function VerificationIntroPage() {
size="l"
stretched
style={{ borderRadius: "12px" }}
- disabled={showSteps && !userData}
+ disabled={showSteps && !userId}
onClick={initiateVerification}
>
{status === "DECLINED" ||
diff --git a/src/components/auth/BrowserLoginScreen.tsx b/src/components/auth/BrowserLoginScreen.tsx
new file mode 100644
index 0000000..8f4fcfd
--- /dev/null
+++ b/src/components/auth/BrowserLoginScreen.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import Link from "next/link";
+import { Button, Title } from "@telegram-apps/telegram-ui";
+import { useTheme } from "@/app/context/theme";
+import { authenticateWithTelegramOidc, saveAuthToStorage } from "@/lib/auth";
+import type { TelegramOidcAuthData } from "@/lib/auth";
+import { TELEGRAM_BOT_URL } from "@/lib/constants";
+import TelegramLoginButton from "@/components/auth/TelegramLoginButton";
+
+interface BrowserLoginScreenProps {
+ onLoggedIn: () => void;
+}
+
+/**
+ * Shown when the app is opened outside Telegram (a plain browser tab).
+ * Signs the user in via Telegram's Login Widget rather than Mini App
+ * initData, since initData only exists inside the Telegram WebView.
+ */
+export default function BrowserLoginScreen({ onLoggedIn }: BrowserLoginScreenProps) {
+ const { isDark } = useTheme();
+ const [error, setError] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleAuth = async (data: TelegramOidcAuthData) => {
+ setError(null);
+
+ if (data.error || !data.id_token) {
+ setError("Couldn't sign you in with Telegram. Please try again.");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ const result = await authenticateWithTelegramOidc(data.id_token);
+ if (!result.token) throw new Error("No token returned");
+ saveAuthToStorage(result.token);
+ onLoggedIn();
+ } catch (err) {
+ console.error(err);
+ setError("Couldn't sign you in with Telegram. Please try again.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ Sign In With Telegram
+
+
+
+ Bitcoin Deepa runs best inside Telegram. Sign in with your Telegram account to continue in
+ the browser.
+
+
+
+
+
+
+ {isLoading && (
+
+ Signing you in…
+
+ )}
+ {error && {error}
}
+
+
+
+ Open in Telegram Instead
+
+
+
+ );
+}
diff --git a/src/components/auth/TelegramLoginButton.tsx b/src/components/auth/TelegramLoginButton.tsx
new file mode 100644
index 0000000..ed95e45
--- /dev/null
+++ b/src/components/auth/TelegramLoginButton.tsx
@@ -0,0 +1,54 @@
+"use client";
+
+import { useEffect, useId, useRef } from "react";
+import { TELEGRAM_CLIENT_ID } from "@/lib/constants";
+import type { TelegramOidcAuthData } from "@/lib/auth";
+
+interface TelegramLoginButtonProps {
+ onAuth: (data: TelegramOidcAuthData) => void;
+ className?: string;
+}
+
+/**
+ * Renders Telegram's Login library (https://core.telegram.org/bots/telegram-login) —
+ * the current OIDC-based login, which superseded the legacy iframe widget. The
+ * script scans the DOM for a `.tg-auth-button` element and wires a popup-based
+ * login flow to it; the onauth callback receives { id_token, user, error }.
+ * The id_token is a signed JWT that must be verified server-side (JWKS), not a
+ * hash to check against the bot token.
+ */
+export default function TelegramLoginButton({ onAuth, className }: TelegramLoginButtonProps) {
+ const containerRef = useRef(null);
+ const rawId = useId().replace(/[^a-zA-Z0-9]/g, "");
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const callbackName = `telegramOidcCallback_${rawId}`;
+ (window as unknown as Record)[callbackName] = (data: TelegramOidcAuthData) =>
+ onAuth(data);
+
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "tg-auth-button";
+ button.textContent = "Sign In with Telegram";
+
+ const script = document.createElement("script");
+ script.src = "https://oauth.telegram.org/js/telegram-login.js?5";
+ script.async = true;
+ script.setAttribute("data-client-id", TELEGRAM_CLIENT_ID);
+ script.setAttribute("data-onauth", `${callbackName}(data)`);
+ script.setAttribute("data-request-access", "write");
+
+ container.appendChild(button);
+ container.appendChild(script);
+
+ return () => {
+ delete (window as unknown as Record)[callbackName];
+ container.innerHTML = "";
+ };
+ }, [onAuth, rawId]);
+
+ return
;
+}
diff --git a/src/components/dashboard/wallet/DashboardTopBar.tsx b/src/components/dashboard/wallet/DashboardTopBar.tsx
index b2d2fac..51c30ca 100644
--- a/src/components/dashboard/wallet/DashboardTopBar.tsx
+++ b/src/components/dashboard/wallet/DashboardTopBar.tsx
@@ -1,13 +1,20 @@
"use client";
-import { Avatar } from "@telegram-apps/telegram-ui";
+import { useRouter } from "next/navigation";
+import { Avatar, IconButton } from "@telegram-apps/telegram-ui";
+import { LogOut } from "lucide-react";
import { VisibleToggle } from "@/components/ui/visible-toggle";
import { useUser } from "@/hooks/useUser";
import { useStore } from "@/lib/store";
+import { useIsTelegramEnv } from "@/hooks/useIsTelegramEnv";
+import { clearAuthFromStorage } from "@/lib/auth";
+import { haptic } from "@/lib/haptics";
export function DashboardTopBar() {
+ const router = useRouter();
const { initials, displayName, photoUrl } = useUser();
const { balanceVisible, toggleBalanceVisible } = useStore();
+ const isTelegramEnv = useIsTelegramEnv();
return (
@@ -22,11 +29,27 @@ export function DashboardTopBar() {
{displayName}
-
+
+ {isTelegramEnv === false && (
+ {
+ haptic.impact("medium");
+ clearAuthFromStorage();
+ router.replace("/");
+ }}
+ >
+
+
+ )}
+
+
);
}
diff --git a/src/hooks/useIsTelegramEnv.ts b/src/hooks/useIsTelegramEnv.ts
new file mode 100644
index 0000000..ceea4eb
--- /dev/null
+++ b/src/hooks/useIsTelegramEnv.ts
@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { isTMA } from "@telegram-apps/sdk-react";
+
+/**
+ * Non-throwing check for whether the app is running inside the Telegram
+ * client. Unlike useLaunchParams()/useInitData(), this never throws, so it's
+ * safe to call before deciding whether to mount Telegram-SDK-dependent UI.
+ *
+ * Uses the async, "complete" variant of isTMA() rather than the sync one:
+ * root layout loads Telegram's telegram-web-app.js unconditionally, which
+ * stubs out window.Telegram.WebApp with empty initData even in a plain
+ * browser, so the sync heuristic (which just checks for that object's
+ * presence) reports a false positive. The async variant round-trips an
+ * actual Mini Apps method call and only resolves true if something answers.
+ *
+ * Returns null until the check has resolved on the client.
+ */
+export function useIsTelegramEnv(): boolean | null {
+ const [isTelegramEnv, setIsTelegramEnv] = useState(null);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ // Cast around the ambient overload set here — depending on which of
+ // @telegram-apps/sdk's and @telegram-apps/bridge's declarations TS
+ // resolves through the re-export chain, it sometimes only "sees" the
+ // sync (0-arg) signature and rejects the 'complete' call below.
+ const isTMAComplete = isTMA as unknown as (type: "complete") => Promise;
+
+ isTMAComplete("complete")
+ .then((result) => {
+ if (!cancelled) setIsTelegramEnv(result);
+ })
+ .catch(() => {
+ if (!cancelled) setIsTelegramEnv(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ return isTelegramEnv;
+}
diff --git a/src/hooks/useTelegramPlatform.ts b/src/hooks/useTelegramPlatform.ts
index fb4b1c0..ef13c51 100644
--- a/src/hooks/useTelegramPlatform.ts
+++ b/src/hooks/useTelegramPlatform.ts
@@ -1,6 +1,6 @@
"use client";
-import { useLaunchParams } from "@telegram-apps/sdk-react";
+import { retrieveLaunchParams } from "@telegram-apps/sdk-react";
const MOBILE_PLATFORMS = ["ios", "android"];
@@ -8,10 +8,24 @@ const MOBILE_PLATFORMS = ["ios", "android"];
* Identifies the Telegram client the Mini App is running in.
* `isMobile` is true only for the native iOS/Android apps — used to gate
* features (e.g. camera-based KYC) that don't work on desktop/web clients.
+ *
+ * Uses the plain retrieveLaunchParams() function (not the useLaunchParams()
+ * hook) in a try/catch — the hook throws outside Telegram even with the ssr
+ * flag set, whereas this is just a regular function call we can safely
+ * swallow errors from.
*/
export function useTelegramPlatform() {
- const launchParams = useLaunchParams(true);
- const platform = launchParams?.platform;
+ let platform: string | undefined;
+ try {
+ // Cast around the same ambient-overload flakiness noted in
+ // useIsTelegramEnv.ts — TS sometimes only resolves the 0-arg signature.
+ const retrieveCamelCased = retrieveLaunchParams as unknown as (camelCase: true) => {
+ platform?: string;
+ };
+ platform = retrieveCamelCased(true).platform;
+ } catch {
+ platform = undefined;
+ }
return {
platform,
diff --git a/src/hooks/useTgBackButton.ts b/src/hooks/useTgBackButton.ts
index 3eb4f5e..875a7f3 100644
--- a/src/hooks/useTgBackButton.ts
+++ b/src/hooks/useTgBackButton.ts
@@ -1,11 +1,16 @@
"use client";
import { useEffect, useRef } from "react";
-import { useBackButton } from "@telegram-apps/sdk-react";
+import { useBackButtonRaw } from "@telegram-apps/sdk-react";
-/** Shows the native Telegram back chevron and wires it to `onBack` for the page's lifetime. */
+/**
+ * Shows the native Telegram back chevron and wires it to `onBack` for the
+ * page's lifetime. Uses the "raw" (non-throwing) resource hook — the plain
+ * useBackButton() throws outside Telegram, where there's no such control to
+ * show; here we just no-op instead.
+ */
export function useTelegramBackButton(onBack: () => void) {
- const backButton = useBackButton();
+ const backButton = useBackButtonRaw(true)?.result;
// Callers typically pass an inline arrow function, which is a new reference
// every render. Routing the call through a ref keeps the effect's dependency
@@ -14,6 +19,8 @@ export function useTelegramBackButton(onBack: () => void) {
onBackRef.current = onBack;
useEffect(() => {
+ if (!backButton) return;
+
backButton.show();
const handleClick = () => onBackRef.current();
backButton.on("click", handleClick);
diff --git a/src/hooks/useUser.ts b/src/hooks/useUser.ts
index 1ad114c..ca0e800 100644
--- a/src/hooks/useUser.ts
+++ b/src/hooks/useUser.ts
@@ -1,35 +1,79 @@
"use client";
-import { useLaunchParams } from "@telegram-apps/sdk-react";
+import { retrieveLaunchParams } from "@telegram-apps/sdk-react";
import { useStore } from "@/lib/store";
import { useSubscriptionCurrent } from "@/hooks/query/useSubscriptionCurrent";
import { useKycStatus } from "@/hooks/query/useKyc";
+import { getUserFromToken } from "@/lib/auth";
/**
- * Single source of truth for user-related data: Telegram identity (client-only),
- * registration status (store, synced by useAuthGuard), and server state
- * (subscription, KYC status) — so components don't each re-derive these.
+ * Identity from Telegram's initData when available (in Telegram), falling
+ * back to the id/username embedded in the stored auth token (outside
+ * Telegram, e.g. a browser session signed in via the Login Widget).
+ * Uses the plain retrieveLaunchParams() function rather than the
+ * useLaunchParams() hook, since the hook throws outside Telegram.
+ */
+interface CamelCasedTelegramUser {
+ id?: number;
+ username?: string;
+ firstName?: string;
+ lastName?: string;
+ photoUrl?: string;
+}
+
+function getIdentity() {
+ try {
+ // Cast around the same ambient-overload flakiness noted in
+ // useIsTelegramEnv.ts — TS sometimes only resolves the 0-arg signature.
+ const retrieveCamelCased = retrieveLaunchParams as unknown as (camelCase: true) => {
+ initData?: { user?: CamelCasedTelegramUser };
+ };
+ const telegramUser = retrieveCamelCased(true).initData?.user;
+ if (telegramUser) {
+ return {
+ id: telegramUser.id?.toString() ?? "",
+ username: telegramUser.username,
+ firstName: telegramUser.firstName,
+ lastName: telegramUser.lastName,
+ photoUrl: telegramUser.photoUrl,
+ };
+ }
+ } catch {
+ // Not running inside Telegram — fall through to the token-based identity.
+ }
+
+ const tokenUser = getUserFromToken();
+ return {
+ id: tokenUser?.id ?? "",
+ username: tokenUser?.username,
+ firstName: undefined as string | undefined,
+ lastName: undefined as string | undefined,
+ photoUrl: undefined as string | undefined,
+ };
+}
+
+/**
+ * Single source of truth for user-related data: identity, registration
+ * status (store, synced by useAuthGuard), and server state (subscription,
+ * KYC status) — so components don't each re-derive these.
*/
export function useUser() {
- const launchParams = useLaunchParams();
- const telegramUser = launchParams.initData?.user;
+ const { id, username, firstName, lastName, photoUrl } = getIdentity();
const { isExistingUser } = useStore();
const { data: subscription, isLoading: subscriptionLoading } = useSubscriptionCurrent();
const { data: kycData } = useKycStatus();
- const username = telegramUser?.username;
const displayName = username ?? "User";
const initials = username?.slice(0, 2).toUpperCase() ?? "BD";
return {
- id: telegramUser?.id?.toString() ?? "",
+ id,
username,
- displayName:
- (telegramUser?.firstName && telegramUser?.lastName
- ? `${telegramUser.firstName} ${telegramUser.lastName}`
- : telegramUser?.firstName) ?? displayName,
+ firstName,
+ lastName,
+ displayName: (firstName && lastName ? `${firstName} ${lastName}` : firstName) ?? displayName,
initials,
- photoUrl: telegramUser?.photoUrl,
+ photoUrl,
isExistingUser,
subscription: subscription ?? null,
subscriptionLoading,
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 379090f..57166e7 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -20,6 +20,25 @@ export interface PlanSelectionData {
planName: string;
}
+/**
+ * Payload Telegram's Login library (https://core.telegram.org/bots/telegram-login)
+ * passes to the onauth callback — distinct from Mini App initData, this is
+ * used for browser-based sign-in outside Telegram. id_token is a signed JWT
+ * that must be verified server-side (JWKS) before the claims in `user` are trusted.
+ */
+export interface TelegramOidcAuthData {
+ id_token?: string;
+ user?: {
+ id: number;
+ name?: string;
+ given_name?: string;
+ family_name?: string;
+ preferred_username?: string;
+ picture?: string;
+ };
+ error?: string;
+}
+
/**
* Authenticate user with Telegram initData
*/
@@ -60,6 +79,38 @@ export async function authenticateWithTelegram(initData: string): Promise {
+ try {
+ const response = await fetch("/api/auth/telegram-oidc", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ id_token: idToken }),
+ });
+
+ const result = await response.json();
+
+ if (!response.ok) {
+ throw new Error(result.error || result.message || "Telegram login failed");
+ }
+
+ return {
+ token: result.token,
+ user: result.user,
+ isRegistered: result.isRegistered ?? true,
+ };
+ } catch (error) {
+ console.error("Error during Telegram OIDC authentication:", error);
+ throw error;
+ }
+}
+
/**
* Register a new user with the backend
*/
@@ -178,3 +229,33 @@ export const clearAuthFromStorage = () => {
export const isAuthenticated = (): boolean => {
return !!getAuthTokenFromStorage();
};
+
+/**
+ * Decodes the stored JWT's payload for identity ({ id, username }) without
+ * verifying it — used as a fallback source of identity outside Telegram,
+ * where there's no initData to read from. Both auth flows (initData and
+ * the Login Widget) mint tokens with the same payload shape, so this works
+ * regardless of which one signed the user in.
+ */
+export const getUserFromToken = (): { id: string; username?: string } | null => {
+ const token = getAuthTokenFromStorage();
+ if (!token) return null;
+
+ try {
+ const payloadSegment = token.split(".")[1];
+ if (!payloadSegment) return null;
+
+ const base64 = payloadSegment.replace(/-/g, "+").replace(/_/g, "/");
+ const json = decodeURIComponent(
+ atob(base64)
+ .split("")
+ .map((c) => "%" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join("")
+ );
+ const payload = JSON.parse(json);
+
+ return typeof payload.id === "string" ? { id: payload.id, username: payload.username } : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index a2a052d..cb6f9f7 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -2,3 +2,7 @@ export const TELEGRAM_BOT_USERNAME =
process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || "BitcoinDeepaBot";
export const TELEGRAM_BOT_URL = `https://t.me/${TELEGRAM_BOT_USERNAME}`;
+
+// Client ID for Telegram Login (OIDC), from @BotFather -> bot -> Login Widget.
+// Public identifier, not a secret — see https://core.telegram.org/bots/telegram-login
+export const TELEGRAM_CLIENT_ID = process.env.NEXT_PUBLIC_TELEGRAM_CLIENT_ID || "";
From 51fe9ed8f13b0a9e31dca063b6d1cc92318b09dc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E1=8F=92=CE=B1=E1=83=A7=CE=B1=CE=B1=C9=B3?=
<108685206+rayaanr@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:29:05 +0530
Subject: [PATCH 23/24] fix: Wallet metrics and enhance UI components (#85)
* feat: refactor wallet metrics and improve UI components for better performance and user experience
* feat: update caching strategy and revalidation for news articles to enhance performance
* feat: update favicon and add metadata for improved site identity
* feat: implement PWA
---
next.config.mjs | 20 ++
public/apple-touch-icon.png | Bin 0 -> 28897 bytes
public/icons/icon-192.png | Bin 0 -> 38912 bytes
public/icons/icon-512.png | Bin 0 -> 178732 bytes
public/icons/icon-maskable-192.png | Bin 0 -> 21707 bytes
public/icons/icon-maskable-512.png | Bin 0 -> 99181 bytes
public/offline.html | 49 +++++
public/sw.js | 71 +++++++
src/app/dashboard/news/[id]/page.tsx | 2 +-
src/app/dashboard/news/page.tsx | 2 +-
src/app/dashboard/page.tsx | 45 ++---
src/app/dashboard/plans/page.tsx | 19 +-
src/app/favicon.ico | Bin 25931 -> 15086 bytes
src/app/layout.tsx | 22 +++
src/app/manifest.ts | 41 ++++
src/app/plans/choose/page.tsx | 16 +-
src/app/register-sw.tsx | 12 ++
.../dashboard/activity/ActivityRow.tsx | 12 +-
.../dashboard/tasks/ManageTasksSection.tsx | 20 ++
.../dashboard/wallet/TotalValueCard.tsx | 7 +-
src/components/motion/number-ticker.tsx | 176 ++++++++++++++++++
src/components/ui/plan-card.tsx | 48 ++---
src/hooks/query/useWalletMetrics.ts | 55 ++++++
src/lib/ease.ts | 57 ++++++
src/lib/news.ts | 9 +-
25 files changed, 603 insertions(+), 80 deletions(-)
create mode 100644 public/apple-touch-icon.png
create mode 100644 public/icons/icon-192.png
create mode 100644 public/icons/icon-512.png
create mode 100644 public/icons/icon-maskable-192.png
create mode 100644 public/icons/icon-maskable-512.png
create mode 100644 public/offline.html
create mode 100644 public/sw.js
create mode 100644 src/app/manifest.ts
create mode 100644 src/app/register-sw.tsx
create mode 100644 src/components/motion/number-ticker.tsx
create mode 100644 src/hooks/query/useWalletMetrics.ts
create mode 100644 src/lib/ease.ts
diff --git a/next.config.mjs b/next.config.mjs
index 1c4efaa..2485c61 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,6 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
allowedDevOrigins: ["*.sats.day", "*.ngrok-free.app"],
+ experimental: {
+ // Since Next 15, page segments aren't reused across Link/router.push
+ // navigation by default (only layouts + true back/forward are). This
+ // lets the news list <-> article navigation reuse the client RSC cache
+ // instead of re-requesting the server on every tap.
+ staleTimes: {
+ dynamic: 60,
+ static: 900,
+ },
+ },
async headers() {
return [
{
@@ -10,6 +20,16 @@ const nextConfig = {
source: "/((?!_next/static|_next/image).*)",
headers: [{ key: "Cache-Control", value: "no-store, must-revalidate" }],
},
+ {
+ // Service worker script: never cache (so updates ship immediately)
+ // and lock down what it's allowed to load.
+ source: "/sw.js",
+ headers: [
+ { key: "Content-Type", value: "application/javascript; charset=utf-8" },
+ { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
+ { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
+ ],
+ },
];
},
};
diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..eda67b14f2ba37563b885301a2f1055a73a02144
GIT binary patch
literal 28897
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
z
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
I#3<y%KEd7HNpK79IuIi2
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