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 0000000..eda67b1 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..7fb508e Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..34c0423 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-192.png b/public/icons/icon-maskable-192.png new file mode 100644 index 0000000..9954a55 Binary files /dev/null and b/public/icons/icon-maskable-192.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..43f0c67 Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/offline.html b/public/offline.html new file mode 100644 index 0000000..fbd0725 --- /dev/null +++ b/public/offline.html @@ -0,0 +1,49 @@ + + + + + + You're offline — Bitcoin Deepa + + + + +

You're offline. Check your connection and try again.

+ + + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..ea7e69a --- /dev/null +++ b/public/sw.js @@ -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)); + } +}); diff --git a/src/app/api/transaction/bot-history/route.ts b/src/app/api/transaction/bot-history/route.ts new file mode 100644 index 0000000..0046d98 --- /dev/null +++ b/src/app/api/transaction/bot-history/route.ts @@ -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 " 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", + }); + + 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 } + ); + } +} diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index e24397e..ecce05f 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -7,26 +7,45 @@ 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("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(); + }; const comingSoon = COMING_SOON_CATEGORIES.includes(category); @@ -34,7 +53,10 @@ export default function ActivityPage() { 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; + } if (!search.trim()) return true; const query = search.trim().toLowerCase(); @@ -92,7 +114,7 @@ export default function ActivityPage() { diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index f482d9e..5ae98b2 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -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 (
{children} diff --git a/src/app/dashboard/news/[id]/page.tsx b/src/app/dashboard/news/[id]/page.tsx index 0c2034e..732b619 100644 --- a/src/app/dashboard/news/[id]/page.tsx +++ b/src/app/dashboard/news/[id]/page.tsx @@ -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, diff --git a/src/app/dashboard/news/page.tsx b/src/app/dashboard/news/page.tsx index ab3c0b5..0c78af4 100644 --- a/src/app/dashboard/news/page.tsx +++ b/src/app/dashboard/news/page.tsx @@ -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 ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 56b96e8..b67de58 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -5,8 +5,7 @@ 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"; @@ -14,33 +13,21 @@ 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 (
diff --git a/src/app/dashboard/plans/page.tsx b/src/app/dashboard/plans/page.tsx index b1610fa..e989485 100644 --- a/src/app/dashboard/plans/page.tsx +++ b/src/app/dashboard/plans/page.tsx @@ -9,7 +9,7 @@ import { GiftPlansComingSoon } from "@/components/dashboard/plans/GiftPlanAction import { ManageGiftsSection } from "@/components/dashboard/plans/ManageGiftsSection"; import { MOCK_GIFTS } from "@/components/dashboard/plans/mock-data"; import { KycRequiredNotice } from "@/components/dashboard/plans/KycRequiredNotice"; -import { useWalletSummary } from "@/hooks/query/useWalletSummary"; +import { useWalletMetrics } from "@/hooks/query/useWalletMetrics"; import { useKycStatus } from "@/hooks/query/useKyc"; import { useSubscriptionCurrent } from "@/hooks/query/useSubscriptionCurrent"; import LoadingPage from "@/components/LoadingPage"; @@ -20,7 +20,12 @@ export default function PlansPage() { const { data: kyc, isLoading: kycLoading } = useKycStatus(); const { balanceVisible, toggleBalanceVisible } = useStore(); const { data: subscription, isLoading: subscriptionLoading } = useSubscriptionCurrent(); - const { data: summary, isLoading: summaryLoading } = useWalletSummary(); + const { + isLoading: summaryLoading, + dcaSpent: investedLkr, + dcaSats: investedSats, + currentValueLkr, + } = useWalletMetrics(); const kycApproved = kyc?.status === "APPROVED"; const noActivePlan = kycApproved && !subscriptionLoading && !subscription; @@ -33,16 +38,6 @@ export default function PlansPage() { if (!kycApproved) return ; if (noActivePlan) return ; - const currentValueLkr = summary - ? Number( - typeof summary.total_lkr === "string" - ? summary.total_lkr.replace(/,/g, "") - : summary.total_lkr - ) - : 0; - const investedLkr = summary?.dca.spent ?? 0; - const investedSats = summary?.dca.balance ?? 0; - return (
+ {children} diff --git a/src/app/manifest.ts b/src/app/manifest.ts new file mode 100644 index 0000000..2d209e1 --- /dev/null +++ b/src/app/manifest.ts @@ -0,0 +1,41 @@ +import type { MetadataRoute } from "next"; + +export default function manifest(): MetadataRoute.Manifest { + return { + name: "Bitcoin Deepa", + short_name: "Bitcoin Deepa", + description: + "Bitcoin membership reward accrual with subscription management for Sri Lankan users.", + start_url: "/dashboard", + scope: "/", + display: "standalone", + background_color: "#0B0F14", + theme_color: "#fa7119", + icons: [ + { + src: "/icons/icon-192.png", + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/icon-512.png", + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + { + src: "/icons/icon-maskable-192.png", + sizes: "192x192", + type: "image/png", + purpose: "maskable", + }, + { + src: "/icons/icon-maskable-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable", + }, + ], + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b576e1c..9324205 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -162,6 +162,7 @@ function UserScreen({ isExisting }: { isExisting: boolean }) { // 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 router = useRouter(); const initLaunchParams = useLaunchParams().initData; const launchParams = useLaunchParams(); const initData = useInitData(); @@ -172,7 +173,7 @@ function TelegramHome() { return initLaunchParams || initData; }, [initLaunchParams, initData]); - useRegisterTelegramUser(authData, launchParams); + const registerUser = useRegisterTelegramUser(authData, launchParams); useEffect(() => { setUserID(authData?.user?.id?.toString() || ""); @@ -186,6 +187,34 @@ function TelegramHome() { } }, [isExistingUser]); + // Cached from a previous launch's 409 response (see + // useRegisterTelegramUser) — lets a known returning user skip straight to + // the dashboard without waiting on the network at all. + const [cachedReturningUser] = useState(() => getIsExistingUserFromStorage()); + + // /api/user (Mongo-backed) already fires on every launch — read its + // result instead of ignoring it. status 409 means this Telegram id was + // already registered, i.e. a returning user, who skips straight to the + // dashboard. It only fires when the profile has a username; without one, + // treat the visitor as new rather than waiting on a call that never runs. + const canCheckExisting = !!(authData?.user?.id && authData?.user?.username); + const isReturningUser = cachedReturningUser || registerUser.data?.status === 409; + const checkPending = + !cachedReturningUser && + canCheckExisting && + registerUser.data === undefined && + !registerUser.isError; + + useEffect(() => { + if (isReturningUser) { + router.replace("/dashboard?tab=wallet"); + } + }, [isReturningUser, router]); + + if (checkPending || isReturningUser) { + return {null}; + } + return ( diff --git a/src/app/plans/choose/page.tsx b/src/app/plans/choose/page.tsx index 6a5100f..8337e6f 100644 --- a/src/app/plans/choose/page.tsx +++ b/src/app/plans/choose/page.tsx @@ -160,14 +160,14 @@ export default function ChoosePlanPage() { if (kycLoading) return ; if (kyc?.status !== "APPROVED") { return ( -
+
); } return ( -
+
{currentPlan ? ( ) : ( @@ -208,9 +208,9 @@ export default function ChoosePlanPage() {
{packagesLoading ? ( -
+
{[0, 1, 2].map((i) => ( - + ))}
) : filteredPlans.length === 0 ? ( @@ -218,7 +218,7 @@ export default function ChoosePlanPage() {

No {duration} plans available

) : ( -
+
{filteredPlans.map((plan) => { const perMonth = perMonthAmount(plan); const perYear = perMonth * 12; @@ -229,9 +229,9 @@ export default function ChoosePlanPage() { } name={plan.name} diff --git a/src/app/register-sw.tsx b/src/app/register-sw.tsx new file mode 100644 index 0000000..ef47f28 --- /dev/null +++ b/src/app/register-sw.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { useEffect } from "react"; + +export function RegisterServiceWorker() { + useEffect(() => { + if (!("serviceWorker" in navigator)) return; + navigator.serviceWorker.register("/sw.js").catch(() => {}); + }, []); + + return null; +} diff --git a/src/components/dashboard/activity/ActivityRow.tsx b/src/components/dashboard/activity/ActivityRow.tsx index 0e5bf46..e81286f 100644 --- a/src/components/dashboard/activity/ActivityRow.tsx +++ b/src/components/dashboard/activity/ActivityRow.tsx @@ -121,7 +121,7 @@ export function ActivityRow({ item, visible }: ActivityRowProps) {

Date

-

+

{fmtShortDate(item.settlement.settledOn)}

@@ -131,26 +131,41 @@ export function ActivityRow({ item, visible }: ActivityRowProps) {

BTC Value {isOutgoing ? "Sent" : "Received"}

-

+

{mask(`${formatSatoshis(item.settlement.btcSats)} BTC`)}

-
-

BTC price at transaction time

-

- {item.settlement.btcPriceUsd !== undefined && ( - <> - USD{" "} - {item.settlement.btcPriceUsd.toFixed(2)} - / BTC · - - )} - රු.{" "} - {fmtPriceCompact(item.settlement.btcPriceLkr)} - / BTC -

-
+ {item.settlement.btcPriceLkr !== undefined ? ( +
+

BTC price at transaction time

+

+ {item.settlement.btcPriceUsd !== undefined && ( + <> + USD{" "} + + {item.settlement.btcPriceUsd.toFixed(2)} + + / BTC · + + )} + රු.{" "} + + {fmtPriceCompact(item.settlement.btcPriceLkr)} + + / BTC +

+
+ ) : ( + item.settlement.memo && ( +
+

Memo

+

+ {item.settlement.memo} +

+
+ ) + )}

Transaction ID

diff --git a/src/components/dashboard/news/NewsListSection.tsx b/src/components/dashboard/news/NewsListSection.tsx index 4f5b543..6895bf2 100644 --- a/src/components/dashboard/news/NewsListSection.tsx +++ b/src/components/dashboard/news/NewsListSection.tsx @@ -33,14 +33,14 @@ export function NewsArticleCard({ article }: { article: NewsArticle }) { )}
-

+

{article.title}

-
- - {article.readTimeMinutes} min read - · - {article.category} +
+ + {article.readTimeMinutes} min read + · + {article.category}
diff --git a/src/components/dashboard/plans/PlanHeroCard.tsx b/src/components/dashboard/plans/PlanHeroCard.tsx index 2d152c0..dd9a751 100644 --- a/src/components/dashboard/plans/PlanHeroCard.tsx +++ b/src/components/dashboard/plans/PlanHeroCard.tsx @@ -273,9 +273,9 @@ export function PlanHeroCard({ }`} > {mask( - `${isProfit ? "+" : "-"}${Math.abs(profitPct).toFixed(0)}% · ${fmtLkrCurrency( - Math.abs(profitLkr) - )}` + `${fmtLkrCurrency(Math.abs(profitLkr))} (${isProfit ? "+" : "-"}${Math.abs( + profitPct + ).toFixed(0)}%)` )} ) diff --git a/src/components/dashboard/tasks/ManageTasksSection.tsx b/src/components/dashboard/tasks/ManageTasksSection.tsx index 11a40ba..4ae5604 100644 --- a/src/components/dashboard/tasks/ManageTasksSection.tsx +++ b/src/components/dashboard/tasks/ManageTasksSection.tsx @@ -1,11 +1,14 @@ "use client"; import { useMemo, useState } from "react"; +import { Snackbar } from "@telegram-apps/telegram-ui"; +import { AlertCircle } from "lucide-react"; import { TaskFilterTabs, type TaskFilter } from "@/components/dashboard/tasks/TaskFilterTabs"; import { TaskListCard } from "@/components/dashboard/tasks/TaskListCard"; import { useTMA } from "@/lib/hooks"; import { haptic } from "@/lib/haptics"; import { useStore } from "@/lib/store"; +import { useIsTelegramEnv } from "@/hooks/useIsTelegramEnv"; import type { Task } from "@/lib/types"; export interface ManageTasksSectionProps { @@ -14,8 +17,10 @@ export interface ManageTasksSectionProps { export function ManageTasksSection({ items }: ManageTasksSectionProps) { const [filter, setFilter] = useState("all"); + const [snackbarError, setSnackbarError] = useState(null); const { openTelegramLink, shareStory } = useTMA(); const { userID, count } = useStore(); + const isTelegramEnv = useIsTelegramEnv(); const filtered = useMemo(() => { if (filter === "all") return items; @@ -33,6 +38,10 @@ export function ManageTasksSection({ items }: ManageTasksSectionProps) { openTelegramLink("https://t.me/+iiP-rX7ldYxjZWU1"); break; case "task-share-story": { + if (!isTelegramEnv) { + setSnackbarError("Open this app inside Telegram to share a story."); + return; + } haptic.impact("heavy"); shareStory("https://ceyloncash.com/bitcoindeepa/tma/story.mp4", { text: `Proud OG Member of Bitcoin දීප. ${count} Citizens and Counting 🚀🔥\n\nhttps://t.me/BitcoinDeepaBot/private_invite?startapp=${userID}\n\n#bitcoindeepa @bitcoindeepabot #viralstory`, @@ -55,6 +64,17 @@ export function ManageTasksSection({ items }: ManageTasksSectionProps) { + + {snackbarError && ( + setSnackbarError(null)} + description={snackbarError} + className="bottom-24! z-100!" + before={} + > + Not Available + + )}
); } diff --git a/src/components/dashboard/wallet/TotalValueCard.tsx b/src/components/dashboard/wallet/TotalValueCard.tsx index cf1d326..878ac09 100644 --- a/src/components/dashboard/wallet/TotalValueCard.tsx +++ b/src/components/dashboard/wallet/TotalValueCard.tsx @@ -3,9 +3,10 @@ import Image from "next/image"; import { ChevronDown, TrendingUp, TrendingDown } from "lucide-react"; import { Card, Badge } from "@telegram-apps/telegram-ui"; +import { NumberTicker } from "@/components/motion/number-ticker"; import { ValueSkeleton } from "@/components/ui/value-skeleton"; import { cn } from "@/lib/cn"; -import { fmtLkrCurrency, fmtSatsCompact, maskDigits } from "@/lib/formatters"; +import { fmtLkr, fmtLkrCurrency, fmtSatsCompact, maskDigits, LKR_SYMBOL } from "@/lib/formatters"; // Card/Badge theme their background/color through --tgui-- CSS variables (same // convention as --tgui--cell--middle--padding on the homepage Cell) — override those @@ -50,6 +51,10 @@ export function TotalValueCard({ {isLoading ? ( + ) : visible ? ( +

+ +

) : (

{mask(`≈ ${fmtLkrCurrency(totalLkr)}`)} diff --git a/src/components/motion/number-ticker.tsx b/src/components/motion/number-ticker.tsx new file mode 100644 index 0000000..3807222 --- /dev/null +++ b/src/components/motion/number-ticker.tsx @@ -0,0 +1,176 @@ +"use client"; +// beui.dev/components/motion/number + +import { animate, motion, useInView, useReducedMotion } from "framer-motion"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { EASE_OUT } from "@/lib/ease"; +import { cn } from "@/lib/cn"; + +export interface NumberTickerProps { + value: number; + /** Digits to pad to (left). */ + pad?: number; + /** Per-digit roll duration in seconds. */ + duration?: number; + /** Stagger between digits. */ + stagger?: number; + /** Render only after the element enters the viewport. */ + startOnView?: boolean; + prefix?: string; + suffix?: string; + /** Add a small blur during digit rolls. */ + blur?: boolean; + className?: string; + digitClassName?: string; + /** Insert locale group separators (commas). Server-component safe. */ + locale?: boolean; + /** Custom formatter. Client-only — server components must use `locale` instead. */ + format?: (value: number) => string; +} + +const DIGIT_HEIGHT_EM = 1.1; +const DIGITS = Array.from({ length: 10 }, (_, n) => n); + +export function NumberTicker({ + value, + pad, + duration = 0.9, + stagger = 0.04, + startOnView = true, + prefix, + suffix, + blur = false, + className, + digitClassName, + locale, + format, +}: NumberTickerProps) { + const containerRef = useRef(null); + const inView = useInView(containerRef, { once: true, amount: 0.6 }); + const [armed, setArmed] = useState(!startOnView); + + useEffect(() => { + if (startOnView && inView) setArmed(true); + }, [startOnView, inView]); + + const text = useMemo(() => { + const rounded = Math.round(value); + const formatted = format + ? format(rounded) + : locale + ? rounded.toLocaleString() + : rounded.toString(); + return pad ? formatted.padStart(pad, "0") : formatted; + }, [value, pad, format, locale]); + const glyphs = useMemo(() => { + const chars = text.split(""); + // Key by place value (position from the right): a changing digit keeps its + // identity and rolls to the new value instead of remounting and replaying + // from 0. Growing numbers add glyphs on the left without re-keying the + // ones, tens, hundreds already on screen. + return chars.map((char, i) => ({ char, id: `g-${chars.length - 1 - i}` })); + }, [text]); + const readableText = `${prefix ?? ""}${text}${suffix ?? ""}`; + + // Stagger is an entrance flourish. Once the reveal has played, value + // changes roll every digit immediately — a per-digit delay on live updates + // reads as lag. + const [entered, setEntered] = useState(false); + useEffect(() => { + if (!armed || entered) return; + const total = (duration + glyphs.length * stagger) * 1000; + const t = window.setTimeout(() => setEntered(true), total); + return () => window.clearTimeout(t); + }, [armed, entered, duration, stagger, glyphs.length]); + + return ( + + {readableText} + + + ); +} + +function Digit({ + digit, + delay, + duration, + blur, + className, +}: { + digit: number; + delay: number; + duration: number; + blur: boolean; + className?: string; +}) { + const reduce = useReducedMotion(); + const columnRef = useRef(null); + + useEffect(() => { + if (reduce || !blur || !columnRef.current || !Number.isFinite(digit)) { + return; + } + + const node = columnRef.current; + const controls = animate( + node, + { filter: ["blur(10px)", "blur(0px)"] }, + { + duration: Math.min(duration * 0.75, 0.32), + delay, + ease: EASE_OUT, + } + ); + + return () => { + controls.stop(); + node.style.filter = "blur(0px)"; + }; + }, [blur, delay, digit, duration, reduce]); + + return ( + + + {DIGITS.map((n) => ( + + {n} + + ))} + + + ); +} diff --git a/src/components/ui/plan-card.tsx b/src/components/ui/plan-card.tsx index 4bc6026..e71b64a 100644 --- a/src/components/ui/plan-card.tsx +++ b/src/components/ui/plan-card.tsx @@ -78,13 +78,6 @@ export function PlanCard({ className )} > - {active && ( -

-
-

Current Plan

-
-
- )} {mostPopular && !active && (
@@ -94,34 +87,43 @@ export function PlanCard({
)} -
+
{/* Top row: radio + emoji + name + price */} -
+
{active ? : isHighlighted ? : } -
{emoji}
+
{emoji}

{name}

-
-

- {price} -

-

- {period} -

+
+ {active && ( +
+

+ Current Plan +

+
+ )} +
+

+ {price} +

+

+ {period} +

+
{/* Bottom: description + pricing, revealed only once the plan is selected */} {isHighlighted && ( -
+

{description}

diff --git a/src/hooks/query/useBotTransactionHistory.ts b/src/hooks/query/useBotTransactionHistory.ts new file mode 100644 index 0000000..b773a68 --- /dev/null +++ b/src/hooks/query/useBotTransactionHistory.ts @@ -0,0 +1,75 @@ +"use client"; + +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"; + +export interface BotTransaction { + id: number; + time: string; + direction: "incoming" | "outgoing"; + from_id: number; + to_id: number; + from_user?: string; + to_user?: string; + type: string; + amount: number; + amount_lkr: string; + memo?: string; + success: boolean; +} + +interface BotTransactionsResponse { + success: boolean; + count: number; + limit: number; + offset: number; + transactions: BotTransaction[]; + message?: string; +} + +const PAGE_SIZE = 50; + +// Sats sent/received to other Telegram users via the bot, paginated +// oldest page first via offset/limit, sorted oldest-first once flattened. +export function useBotTransactionHistory() { + const authToken = getAuthTokenFromStorage(); + + const query = useInfiniteQuery({ + queryKey: queryKeys.botTransactions, + queryFn: async ({ pageParam }) => { + const data = await fetchy.get( + `/api/transaction/bot-history?limit=${PAGE_SIZE}&offset=${pageParam}`, + { + headers: { Authorization: `Bearer ${authToken}` }, + shouldCache: false, + } + ); + + const transactions = data.success ? data.transactions : []; + const hasMore = data.success && data.offset + transactions.length < data.count; + + return { + transactions, + nextOffset: data.offset + PAGE_SIZE, + hasMore, + }; + }, + initialPageParam: 0, + getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.nextOffset : undefined), + enabled: !!authToken, + staleTime: 1000 * 60 * 5, + }); + + const transactions = useMemo( + () => + (query.data?.pages ?? []) + .flatMap((page) => page.transactions) + .sort((a, b) => new Date(a.time).getTime() - new Date(b.time).getTime()), + [query.data] + ); + + return { ...query, transactions }; +} diff --git a/src/hooks/query/useRegisterTelegramUser.ts b/src/hooks/query/useRegisterTelegramUser.ts index 06891e3..d2fa45c 100644 --- a/src/hooks/query/useRegisterTelegramUser.ts +++ b/src/hooks/query/useRegisterTelegramUser.ts @@ -3,6 +3,7 @@ import { useEffect } from "react"; import { useMutation } from "@tanstack/react-query"; import fetchy from "@/lib/fetchy"; +import { markIsExistingUserInStorage, clearIsExistingUserInStorage } from "@/lib/auth"; type TelegramAuthData = { user?: { id?: number; username?: string } } | undefined; @@ -12,18 +13,40 @@ type RegisterUserPayload = { data: { authdata: TelegramAuthData; launchparam: unknown }; }; +export interface RegisterUserResponse { + status: number; // 201 = newly created, 409 = already existed, 500 = error + message: string; + user?: unknown; +} + export function useRegisterTelegramUser(authData: TelegramAuthData, launchParams: unknown) { - const { mutate } = useMutation({ - mutationFn: (payload: RegisterUserPayload) => fetchy.post("/api/user", payload), + const mutation = useMutation({ + mutationFn: (payload: RegisterUserPayload) => + fetchy.post("/api/user", payload), + onSuccess: (data) => { + // Keep the cached flag in sync with the authoritative server response + // (this call fires on every launch regardless of cache) — 409 means + // still registered, 201 means the id wasn't found, so any stale + // "existing user" cache from a deleted account gets corrected here. + if (data.status === 409) { + markIsExistingUserInStorage(); + } else if (data.status === 201) { + clearIsExistingUserInStorage(); + } + }, onError: (error) => { console.error("Error adding user to database:", error); }, }); + const { mutate } = mutation; + useEffect(() => { const { username, id } = authData?.user || {}; if (id && username) { mutate({ id, username, data: { authdata: authData, launchparam: launchParams } }); } }, [authData, launchParams, mutate]); + + return mutation; } diff --git a/src/hooks/query/useWalletMetrics.ts b/src/hooks/query/useWalletMetrics.ts new file mode 100644 index 0000000..fff3521 --- /dev/null +++ b/src/hooks/query/useWalletMetrics.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useWalletSummary } from "@/hooks/query/useWalletSummary"; +import { useTransactionHistory } from "@/hooks/query/useTransactionHistory"; + +// Single source of truth for wallet numbers derived from the DCA summary + +// transaction history. Both the dashboard and Manage My Plan read this so +// "Current Value" stays identical across the app instead of drifting when +// one screen's calculation is tweaked and the other isn't. +export function useWalletMetrics() { + const { data: summary, isLoading: isSummaryLoading } = useWalletSummary(); + const { transactions, isLoading: isTransactionsLoading } = 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 changePercent = summary?.["24_hr_change"] ?? 0; + const changeLkr = (changePercent / 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; + + return { + summary, + transactions, + isLoading: isSummaryLoading || isTransactionsLoading, + totalLkr, + totalSats, + dcaSpent, + dcaSats, + avgBtcPrice, + avgBtcPriceUsd, + changePercent, + changeLkr, + currentBtcPrice, + currentBtcPriceUsd, + currentValueLkr, + }; +} diff --git a/src/lib/activity.ts b/src/lib/activity.ts index fa0dea1..c66913e 100644 --- a/src/lib/activity.ts +++ b/src/lib/activity.ts @@ -1,5 +1,6 @@ import type { ActivityItem, ActivityType } from "@/lib/types"; import type { DcaTransaction } from "@/hooks/query/useTransactionHistory"; +import type { BotTransaction } from "@/hooks/query/useBotTransactionHistory"; import { fmtActivityTime } from "@/lib/formatters"; type StatusDot = NonNullable; @@ -86,6 +87,36 @@ export function mapDcaTransactionToActivityItem(tx: DcaTransaction): ActivityIte }; } +// A bot-relayed sats transfer to/from another Telegram user maps onto the +// "sent"/"received" activity type based on `direction`. +function parseLkr(value: string): number { + return Number(value.replace(/,/g, "")) || 0; +} + +export function mapBotTransactionToActivityItem(tx: BotTransaction): ActivityItem { + const isOutgoing = tx.direction === "outgoing"; + const counterpartyRaw = isOutgoing ? tx.to_user : tx.from_user; + const counterparty = counterpartyRaw?.replace(/^@/, "") ?? String(isOutgoing ? tx.to_id : tx.from_id); + const lkr = parseLkr(tx.amount_lkr); + + return { + id: String(tx.id), + type: isOutgoing ? "sent" : "received", + counterparty, + timestamp: tx.time, + sats: isOutgoing ? -tx.amount : tx.amount, + lkr, + statusDot: tx.success ? "green" : "red", + settlement: { + status: tx.success ? "Completed" : "Failed", + settledOn: tx.time, + btcSats: tx.amount, + memo: tx.memo, + transactionId: String(tx.id), + }, + }; +} + export function getActivitySubtitle(item: ActivityItem): string { const time = fmtActivityTime(item.timestamp); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 57166e7..2348126 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -214,6 +214,27 @@ export const getIsExistingUserFromStorage = (): boolean => { return localStorage.getItem("bitcoin-deepa-is-existing-user") === "true"; }; +/** + * Persists the "returning user" flag on its own, independent of an auth + * token — used by the /api/user existing-check (409 response), which never + * hands back a token. Cached so future launches can skip the network round + * trip entirely and redirect straight to the dashboard. + */ +export const markIsExistingUserInStorage = () => { + if (typeof window === "undefined") return; + localStorage.setItem("bitcoin-deepa-is-existing-user", "true"); +}; + +/** + * Clears the returning-user flag — used when the server tells us the id we + * thought was registered actually wasn't (a stale cache from a deleted + * account or DB reset), so the next launch stops trusting it. + */ +export const clearIsExistingUserInStorage = () => { + if (typeof window === "undefined") return; + localStorage.removeItem("bitcoin-deepa-is-existing-user"); +}; + export const saveAuthToStorage = (token: string) => { if (typeof window === "undefined") return; localStorage.setItem("bitcoin-deepa-auth-token", token); diff --git a/src/lib/ease.ts b/src/lib/ease.ts new file mode 100644 index 0000000..144a563 --- /dev/null +++ b/src/lib/ease.ts @@ -0,0 +1,57 @@ +// Shared motion tokens. Easing curves mirror the CSS custom properties in +// globals.css; springs are the canonical physics used across components. +// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak. + +export const EASE_OUT: [number, number, number, number] = [0.16, 1, 0.3, 1]; +export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const; +export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const; + +/** CSS string form of EASE_OUT for inline style transitions. */ +export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)"; + +/** Press feedback on buttons and other tappable surfaces. */ +export const SPRING_PRESS = { + type: "spring", + stiffness: 500, + damping: 30, + mass: 0.6, +} as const; + +/** Content swaps — label/icon slots trading places inside a control. */ +export const SPRING_SWAP = { + type: "spring", + stiffness: 460, + damping: 30, + mass: 0.55, +} as const; + +/** Overlay panel entrances — modals and sheets summoned by pointer. */ +export const SPRING_PANEL = { + type: "spring", + stiffness: 420, + damping: 40, + mass: 0.5, +} as const; + +/** Shared-layout glides — pills, indicators and panels morphing between positions. */ +export const SPRING_LAYOUT = { + type: "spring", + stiffness: 360, + damping: 32, + mass: 0.6, +} as const; + +/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */ +export const SPRING_MOUSE = { + stiffness: 200, + damping: 15, + mass: 0.3, +} as const; + +/** Dragged handles and fills (sliders) — critically damped `useSpring` config, + * so the value follows the pointer butterily and never rebounds off an end. */ +export const SPRING_GLIDE = { + stiffness: 700, + damping: 50, + mass: 0.5, +} as const; diff --git a/src/lib/news.ts b/src/lib/news.ts index 77aacbd..2f85062 100644 --- a/src/lib/news.ts +++ b/src/lib/news.ts @@ -1,5 +1,6 @@ import Parser from "rss-parser"; import sanitizeHtml from "sanitize-html"; +import { unstable_cache } from "next/cache"; import type { NewsArticle } from "@/lib/types"; const FEED_URL = "https://bitcoinmagazine.com/feed"; @@ -105,7 +106,7 @@ function toArticle(item: FeedItem, index: number): NewsArticle | null { }; } -export async function getNewsArticles(): Promise { +async function fetchNewsArticles(): Promise { const response = await fetch(FEED_URL, { next: { revalidate: 900 } }); if (!response.ok) return []; @@ -122,3 +123,9 @@ export async function getNewsArticles(): Promise { .map((item, index) => toArticle(item, index)) .filter((article): article is NewsArticle => article !== null); } + +// Caches the parsed+sanitized article list (not just the raw feed fetch), so the +// list and detail pages share one parse pass instead of redoing it per request. +export const getNewsArticles = unstable_cache(fetchNewsArticles, ["news-articles"], { + revalidate: 900, +}); diff --git a/src/lib/query-keys.ts b/src/lib/query-keys.ts index 46b1442..6a4a8a8 100644 --- a/src/lib/query-keys.ts +++ b/src/lib/query-keys.ts @@ -1,6 +1,7 @@ export const queryKeys = { walletSummary: ["wallet-summary"] as const, transactions: ["transactions"] as const, + botTransactions: ["bot-transactions"] as const, packages: ["packages"] as const, userCount: ["user-count"] as const, subscriptionCurrent: ["subscription-current"] as const, diff --git a/src/lib/types.ts b/src/lib/types.ts index 48fc35f..cc59769 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -113,7 +113,10 @@ export interface ActivityItem { settledOn: string; btcSats: number; btcPriceUsd?: number; - btcPriceLkr: number; + /** Omit when there's no real price for this transaction (e.g. bot transfers) — show `memo` instead */ + btcPriceLkr?: number; + /** Transfer note from the sender, shown in place of the price panel when there's no real price */ + memo?: string; transactionId: string; }; }