Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e136271
feat: enhance visibility toggling for balance and improve formatting …
rayaanr Aug 4, 2026
d48016e
feat: calculate and display average BTC price in USD on WalletPage; a…
rayaanr Aug 4, 2026
b21bbac
feat: add loading components for Activity, Wallet, Plans, and Tasks; …
rayaanr Aug 4, 2026
3846f2a
feat: enhance navigation item activation logic and improve PortfolioC…
rayaanr Aug 4, 2026
0d090fd
feat: replace fmtLkr with fmtLkrCurrency for consistent currency form…
rayaanr Aug 4, 2026
8a97124
feat: update formatting for investment display in PlanHeroCard, Perfo…
rayaanr Aug 4, 2026
1a1c731
feat: add loading state to PerformanceGrid and WalletPage; implement …
rayaanr Aug 4, 2026
bc59527
feat: enhance navigation logic in ChoosePlanPage to prevent redirect …
rayaanr Aug 4, 2026
f1f074a
feat: integrate haptic feedback across various components for enhance…
rayaanr Aug 4, 2026
b38acf6
feat: add dynamic export to NewsArticleDetailPage and NewsPage for im…
rayaanr Aug 4, 2026
1979806
feat: update haptic feedback style from light to medium for improved …
rayaanr Aug 4, 2026
c932dec
feat: enhance loading states across WalletPage, PlansPage, and relate…
rayaanr Aug 4, 2026
b12b89d
feat: update loading state handling in PlansPage and PlanHeroCard for…
rayaanr Aug 4, 2026
9bae4d9
feat: enhance loading skeletons and styles for improved user experien…
rayaanr Aug 4, 2026
2568542
feat: update background opacity in PlanHeroCard and TotalValueCard fo…
rayaanr Aug 4, 2026
8faed26
feat: conditionally render period switcher based on available data po…
rayaanr Aug 4, 2026
4b47bc5
feat: comment out unused imports and functions in PortfolioChart for …
rayaanr Aug 4, 2026
59ac850
feat: refactor TotalValueCard to improve layout and visibility of bad…
rayaanr Aug 4, 2026
6e24eb9
feat: refactor useTransactionHistory to support pagination and improv…
rayaanr Aug 4, 2026
f52f033
feat: update totalLkr calculation to use currentValueLkr for accurate…
rayaanr Aug 4, 2026
e002037
feat: reveal plan details only after selection for improved user inte…
rayaanr Aug 4, 2026
edbad53
feat: implement Telegram authentication flow with widget support for …
helloscoopa Aug 4, 2026
a6b0488
Merge branch 'main' into dev
helloscoopa Aug 5, 2026
51fe9ed
fix: Wallet metrics and enhance UI components (#85)
rayaanr Aug 6, 2026
e42c8da
feat: Add transaction history API and enhance user registration flow …
rayaanr Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
@@ -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 [
{
Expand All @@ -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'" },
],
},
];
},
};
Expand Down
Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-maskable-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/icons/icon-maskable-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
49 changes: 49 additions & 0 deletions public/offline.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>You're offline — Bitcoin Deepa</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0b0f14;
color: #fff;
text-align: center;
padding: 24px;
}
img {
width: 64px;
height: 64px;
opacity: 0.9;
}
p {
color: #94a3b8;
font-size: 14px;
max-width: 280px;
}
button {
margin-top: 8px;
border: none;
border-radius: 12px;
padding: 10px 20px;
background: #fa7119;
color: #fff;
font-size: 14px;
font-weight: 600;
}
</style>
</head>
<body>
<img src="/icons/icon-192.png" alt="" />
<p>You're offline. Check your connection and try again.</p>
<button onclick="location.reload()">Retry</button>
</body>
</html>
71 changes: 71 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const CACHE_VERSION = "v1";
const STATIC_CACHE = `bd-static-${CACHE_VERSION}`;
const OFFLINE_URL = "/offline.html";

const PRECACHE_URLS = [OFFLINE_URL, "/icons/icon-192.png", "/icons/icon-512.png", "/favicon.ico"];

self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(STATIC_CACHE)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting())
);
});

self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== STATIC_CACHE).map((key) => caches.delete(key)))
)
.then(() => self.clients.claim())
);
});

// Navigations (HTML documents) are intentionally network-only, never cached.
// This app forces Cache-Control: no-store on HTML routes to work around a
// Telegram Desktop WebView bug that serves stale pages (see next.config.mjs)
// — caching navigations here would reintroduce that exact bug. We only fall
// back to a static offline page when the network request fails outright.
async function handleNavigation(request) {
try {
return await fetch(request);
} catch {
const cache = await caches.open(STATIC_CACHE);
return (await cache.match(OFFLINE_URL)) ?? Response.error();
}
}

// Hashed Next.js build assets and local images are immutable — safe to
// cache-first indefinitely.
async function handleStaticAsset(request) {
const cache = await caches.open(STATIC_CACHE);
const cached = await cache.match(request);
if (cached) return cached;

const response = await fetch(request);
if (response.ok) cache.put(request, response.clone());
return response;
}

self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;

const url = new URL(request.url);
if (url.origin !== self.location.origin) return;

// Never cache API responses — always fresh, subscription/wallet data included.
if (url.pathname.startsWith("/api/")) return;

if (request.mode === "navigate") {
event.respondWith(handleNavigation(request));
return;
}

if (url.pathname.startsWith("/_next/static/") || url.pathname.startsWith("/icons/")) {
event.respondWith(handleStaticAsset(request));
}
});
67 changes: 67 additions & 0 deletions src/app/api/transaction/bot-history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";

export async function GET(request: Request) {
try {
// Get authorization header
const headersList = await headers();
const authorization = headersList.get("authorization");

if (!authorization) {
return NextResponse.json(
{
error: "Unauthorized",
message: "Authorization header is required",
},
{ status: 401 }
);
}

// Extract token from "Bearer <token>" format
const token = authorization.startsWith("Bearer ") ? authorization.slice(7) : authorization;

// Get pagination parameters from URL
const url = new URL(request.url);
const limit = url.searchParams.get("limit") || "100";
const offset = url.searchParams.get("offset") || "0";

// Build API URL with pagination parameters
const apiUrl = new URL(`${process.env.API_BASE_URL}/transaction/bot-history`);
apiUrl.searchParams.set("limit", limit);
apiUrl.searchParams.set("offset", offset);

// Make request to external API to get current user's bot (sats send/receive) transactions
const response = await fetch(apiUrl.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
cache: "no-cache",
});
Comment on lines +34 to +41

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use fetchy for the upstream request.

Line 34 bypasses the repository HTTP wrapper. Migrate this request to @/lib/fetchy and preserve the authorization header, pagination parameters, and cache behavior.

As per coding guidelines, “All HTTP requests must use the custom fetchy wrapper from @/lib/fetchy.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/transaction/bot-history/route.ts` around lines 34 - 41, Replace
the direct fetch call in the bot-history route with the repository fetchy
wrapper imported from `@/lib/fetchy`, preserving the GET method, Authorization and
content-type headers, pagination query parameters in apiUrl, and no-cache
behavior.

Source: Coding guidelines


if (!response.ok) {
const errorData = await response.text();
return NextResponse.json(
{
error: "Failed to fetch bot transactions",
message: errorData || `HTTP ${response.status}: ${response.statusText}`,
},
{ status: response.status }
);
}

const data = await response.json();

return NextResponse.json(data);
} catch (error) {
console.error("❌ Error fetching bot transactions:", error);
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error occurred",
},
{ status: 500 }
);
}
}
38 changes: 30 additions & 8 deletions src/app/dashboard/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,34 +7,56 @@ import { ActivityTabs } from "@/components/dashboard/activity/ActivityTabs";
import { ActivityGroup } from "@/components/dashboard/activity/ActivityGroup";
import { ActivitySkeleton } from "@/components/dashboard/activity/ActivitySkeleton";
import { useTransactionHistory } from "@/hooks/query/useTransactionHistory";
import { useBotTransactionHistory } from "@/hooks/query/useBotTransactionHistory";
import {
ACTIVITY_TITLE,
getActivityCategory,
mapBotTransactionToActivityItem,
mapDcaTransactionToActivityItem,
type ActivityCategory,
} from "@/lib/activity";
import { useStore } from "@/lib/store";

// Only membership rewards (DCA purchases) are backed by real data today —
// sent/received/tipjar/faucet/gift and tasks have no backend endpoint yet.
const COMING_SOON_CATEGORIES: ActivityCategory[] = ["transactions", "tasks"];
// Tipjar/faucet/gift and tasks have no backend endpoint yet.
const COMING_SOON_CATEGORIES: ActivityCategory[] = ["tasks"];

export default function ActivityPage() {
const { balanceVisible, toggleBalanceVisible } = useStore();
const [search, setSearch] = useState("");
const [category, setCategory] = useState<ActivityCategory>("all");

const { transactions, isLoading, hasNextPage, isFetchingNextPage, fetchNextPage } =
useTransactionHistory();
const activity = useMemo(() => transactions.map(mapDcaTransactionToActivityItem), [transactions]);
const dca = useTransactionHistory();
const botHistory = useBotTransactionHistory();

const activity = useMemo(
() => [
...dca.transactions.map(mapDcaTransactionToActivityItem),
...botHistory.transactions.map(mapBotTransactionToActivityItem),
],
[dca.transactions, botHistory.transactions]
);

const showDca = category !== "transactions";
const showBotHistory = category === "transactions";
const isLoading =
(showDca && dca.isLoading) || (showBotHistory && botHistory.isLoading);
const hasNextPage = (showDca && dca.hasNextPage) || (showBotHistory && botHistory.hasNextPage);
const isFetchingNextPage = dca.isFetchingNextPage || botHistory.isFetchingNextPage;
const fetchNextPage = () => {
if (showDca && dca.hasNextPage) dca.fetchNextPage();
if (showBotHistory && botHistory.hasNextPage) botHistory.fetchNextPage();
};
Comment on lines +39 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Load both histories in the all category.

showBotHistory is false for all. Therefore, hasNextPage and fetchNextPage never load bot pages after the initial page. Include bot history when category === "all".

Proposed fix
-  const showDca = category !== "transactions";
-  const showBotHistory = category === "transactions";
+  const showDca = category === "all" || category === "plans";
+  const showBotHistory = category === "all" || category === "transactions";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const showDca = category !== "transactions";
const showBotHistory = category === "transactions";
const isLoading =
(showDca && dca.isLoading) || (showBotHistory && botHistory.isLoading);
const hasNextPage = (showDca && dca.hasNextPage) || (showBotHistory && botHistory.hasNextPage);
const isFetchingNextPage = dca.isFetchingNextPage || botHistory.isFetchingNextPage;
const fetchNextPage = () => {
if (showDca && dca.hasNextPage) dca.fetchNextPage();
if (showBotHistory && botHistory.hasNextPage) botHistory.fetchNextPage();
};
const showDca = category === "all" || category === "plans";
const showBotHistory = category === "all" || category === "transactions";
const isLoading =
(showDca && dca.isLoading) || (showBotHistory && botHistory.isLoading);
const hasNextPage = (showDca && dca.hasNextPage) || (showBotHistory && botHistory.hasNextPage);
const isFetchingNextPage = dca.isFetchingNextPage || botHistory.isFetchingNextPage;
const fetchNextPage = () => {
if (showDca && dca.hasNextPage) dca.fetchNextPage();
if (showBotHistory && botHistory.hasNextPage) botHistory.fetchNextPage();
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/dashboard/activity/page.tsx` around lines 39 - 48, The activity
pagination logic currently excludes bot history for the "all" category. Update
showBotHistory and the related hasNextPage/fetchNextPage flow so category "all"
includes bot history while preserving transactions-only behavior for
"transactions".


const comingSoon = COMING_SOON_CATEGORIES.includes(category);

const groups = useMemo(() => {
if (comingSoon) return [];

const filtered = activity.filter((item) => {
if (category !== "all" && getActivityCategory(item.type) !== category) return false;
const itemCategory = getActivityCategory(item.type);
if (category === "all" ? itemCategory === "transactions" : itemCategory !== category) {
return false;
}
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Correct the all category predicate.

When category === "all", Line 57 returns false for every "transactions" item. This excludes all mapped bot transfers from the all-activity view. Only apply the category comparison when category !== "all".

Proposed fix
-      if (category === "all" ? itemCategory === "transactions" : itemCategory !== category) {
+      if (category !== "all" && itemCategory !== category) {
         return false;
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const itemCategory = getActivityCategory(item.type);
if (category === "all" ? itemCategory === "transactions" : itemCategory !== category) {
return false;
}
const itemCategory = getActivityCategory(item.type);
if (category !== "all" && itemCategory !== category) {
return false;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/dashboard/activity/page.tsx` around lines 56 - 59, Update the
filtering predicate using itemCategory in the activity list so category ===
"all" bypasses category filtering and retains every item. Only compare
itemCategory against category when category !== "all", preserving the existing
exclusion behavior for specific categories.

if (!search.trim()) return true;

const query = search.trim().toLowerCase();
Expand Down Expand Up @@ -92,7 +114,7 @@ export default function ActivityPage() {
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
className="py-3 text-center text-[14px] font-semibold text-[#fa7119] disabled:opacity-50"
className="py-3 text-center text-[14px] font-semibold text-[#fa7119] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isFetchingNextPage ? "Loading..." : "Load more"}
</button>
Expand Down
9 changes: 9 additions & 0 deletions src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

import BottomNavigation from "@/components/bottomNavigation";
import { useAuthGuard } from "@/hooks/useAuthGuard";
import { useKycStatus } from "@/hooks/query/useKyc";
import { useSubscriptionCurrent } from "@/hooks/query/useSubscriptionCurrent";

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
useAuthGuard();

// Warm the shared query cache as soon as any dashboard page mounts, not
// just when the plans tab does — by the time the user taps into Plans,
// these are already fetched (or in flight), so it skips straight past
// the kycLoading/noActivePlan LoadingPage states instead of flashing them.
useKycStatus();
useSubscriptionCurrent();

return (
<div className="bg-surface-main mx-auto flex min-h-screen max-w-md flex-col gap-5 px-5 pt-5 pb-22">
{children}
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/news/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NewsDetailBackButton, NewsArticleCard } from "@/components/dashboard/ne
import { fmtShortDate } from "@/lib/formatters";
import { getNewsArticles } from "@/lib/news";

export const dynamic = "force-dynamic";
export const revalidate = 900;

export default async function NewsArticleDetailPage({
params,
Expand Down
2 changes: 1 addition & 1 deletion src/app/dashboard/news/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { NewsListSection } from "@/components/dashboard/news/NewsListSection";
import { getNewsArticles } from "@/lib/news";
import type { NewsArticle } from "@/lib/types";

export const dynamic = "force-dynamic";
export const revalidate = 900;

function NewsFeaturedCard({ article }: { article: NewsArticle }) {
return (
Expand Down
45 changes: 16 additions & 29 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,42 +5,29 @@ import { TotalValueCard } from "@/components/dashboard/wallet/TotalValueCard";
import { PlanSummaryCard } from "@/components/dashboard/wallet/PlanSummaryCard";
import { PerformanceGrid } from "@/components/dashboard/wallet/PerformanceGrid";
import { PortfolioChart } from "@/components/dashboard/wallet/PortfolioChart";
import { useWalletSummary } from "@/hooks/query/useWalletSummary";
import { useTransactionHistory } from "@/hooks/query/useTransactionHistory";
import { useWalletMetrics } from "@/hooks/query/useWalletMetrics";
import { useUser } from "@/hooks/useUser";
import { useStore } from "@/lib/store";

export default function WalletPage() {
const { balanceVisible } = useStore();
const { subscription, subscriptionLoading } = useUser();

const { data: summary, isLoading: isSummaryLoading } = useWalletSummary();
const { transactions } = useTransactionHistory();

const totalLkr = summary
? Number(
typeof summary.total_lkr === "string"
? summary.total_lkr.replace(/,/g, "")
: summary.total_lkr
)
: 0;
const totalSats = summary?.total_balance ?? 0;
const dcaSpent = summary?.dca.spent ?? 0;
const dcaSats = summary?.dca.balance ?? 0;
const avgBtcPrice = summary?.dca.avg_btc_price ?? 0;
const change24h = summary?.["24_hr_change"] ?? 0;
const changeLkr = (change24h / 100) * totalLkr;
const currentBtcPrice =
summary?.current_btc_price?.lkr ??
(transactions?.length
? transactions[transactions.length - 1].btc_price_at_purchase
: avgBtcPrice);
const currentBtcPriceUsd = summary?.current_btc_price?.usd;
const avgBtcPriceUsd =
currentBtcPriceUsd && currentBtcPrice
? avgBtcPrice * (currentBtcPriceUsd / currentBtcPrice)
: undefined;
const currentValueLkr = (dcaSats / 1e8) * currentBtcPrice;
const {
transactions,
isLoading: isSummaryLoading,
totalLkr,
totalSats,
dcaSpent,
dcaSats,
avgBtcPrice,
avgBtcPriceUsd,
changePercent: change24h,
changeLkr,
currentBtcPrice,
currentBtcPriceUsd,
currentValueLkr,
} = useWalletMetrics();

return (
<div className="flex w-full flex-col gap-5">
Expand Down
Loading