From 97cac45ee59e97cbb1753bc94dfb983b078d985e Mon Sep 17 00:00:00 2001 From: feyishola Date: Sun, 26 Apr 2026 15:41:41 +0100 Subject: [PATCH] sdk, infinity scrool and initialization scripts --- contracts/contrib/scripts/.env.example | 16 ++ contracts/contrib/scripts/README.md | 104 ++++++++ contracts/contrib/scripts/deploy.sh | 97 ++++++++ contracts/contrib/scripts/initialize.sh | 68 +++++ contracts/contrib/scripts/setup-testnet.sh | 69 ++++++ .../contrib/app/(dashboard)/assets/page.tsx | 234 ++++++++++-------- .../components/assets/InfiniteAssetList.tsx | 171 +++++++++++++ frontend/contrib/hooks/index.ts | 1 + frontend/contrib/hooks/useInfiniteAssets.ts | 34 +++ 9 files changed, 696 insertions(+), 98 deletions(-) create mode 100644 contracts/contrib/scripts/.env.example create mode 100644 contracts/contrib/scripts/README.md create mode 100755 contracts/contrib/scripts/deploy.sh create mode 100755 contracts/contrib/scripts/initialize.sh create mode 100755 contracts/contrib/scripts/setup-testnet.sh create mode 100644 frontend/contrib/components/assets/InfiniteAssetList.tsx create mode 100644 frontend/contrib/hooks/useInfiniteAssets.ts diff --git a/contracts/contrib/scripts/.env.example b/contracts/contrib/scripts/.env.example new file mode 100644 index 00000000..6e927c53 --- /dev/null +++ b/contracts/contrib/scripts/.env.example @@ -0,0 +1,16 @@ +# ─── Stellar Testnet Configuration ───────────────────────────────────────────── +# Admin account secret key (ed25519 prefixed with "S") +ADMIN_SECRET=S... + +# Stellar network passphrase +NETWORK_PASSPHRASE=Test SDF Network ; September 2015 + +# Soroban RPC endpoint +RPC_URL=https://soroban-testnet.stellar.org:443 + +# ─── Optional ────────────────────────────────────────────────────────────────── +# Network name used by the stellar CLI (--network argument) +NETWORK=testnet + +# Deployed contract ID (populated by deploy.sh, read by initialize.sh) +CONTRACT_ID= diff --git a/contracts/contrib/scripts/README.md b/contracts/contrib/scripts/README.md new file mode 100644 index 00000000..93ea9814 --- /dev/null +++ b/contracts/contrib/scripts/README.md @@ -0,0 +1,104 @@ +# Contrib Contract — Deploy & Initialize on Stellar Testnet + +## Prerequisites + +| Tool | Version | Install | +|------|---------|---------| +| Rust | stable | `rustup default stable` | +| wasm32 target | — | `rustup target add wasm32-unknown-unknown` | +| stellar CLI | >= 22 | `cargo install stellar-cli` | +| curl | any | system package | + +## Environment Setup + +```bash +cd contracts/contrib/scripts + +# 1. Create your .env from the example +cp .env.example .env + +# 2. Fill in your admin secret key +# Generate a new keypair with: stellar keys generate +# Or use an existing one. +$EDITOR .env +``` + +The `.env` file requires three variables: + +| Variable | Description | Example | +|----------|-------------|---------| +| `ADMIN_SECRET` | Ed25519 secret key (starts with `S`) | `SBUV...` | +| `NETWORK_PASSPHRASE` | Stellar network identifier | `Test SDF Network ; September 2015` | +| `RPC_URL` | Soroban RPC endpoint | `https://soroban-testnet.stellar.org:443` | + +## Deploy Workflow + +Run the three scripts in order: + +### Step 1 — Fund the admin account + +```bash +./setup-testnet.sh +``` + +This calls [Friendbot](https://developers.stellar.org/docs/learn/fund-and-create-an-account#testnet) to credit 10,000 XLM to the admin account on Testnet. Safe to re-run (idempotent). + +### Step 2 — Compile & deploy the contract + +```bash +./deploy.sh +``` + +What it does: + +1. Compiles `contrib` to WASM via `cargo build --target wasm32-unknown-unknown --release` +2. Uploads the WASM and creates the contract instance via `stellar contract deploy` +3. Logs the **contract ID** to stdout +4. Persists the contract ID in `.env` as `CONTRACT_ID=...` + +### Step 3 — Initialize the contract + +```bash +./initialize.sh +``` + +What it does: + +1. Derives the admin public address from `ADMIN_SECRET` +2. Calls `initialize(admin)` on the deployed contract via `stellar contract invoke` +3. The contract sets the admin, marks the contract as unpaused, sets total count to 0, and authorizes the admin as a registrar + +### Verify + +```bash +# Check the stored admin +stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source-key "$ADMIN_SECRET" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- get_admin + +# Should print the admin's public address (G...) +``` + +## Redeploying + +To deploy a new instance after code changes: + +```bash +./deploy.sh # builds, deploys, saves new CONTRACT_ID to .env +./initialize.sh # initializes the new instance +``` + +> **Note:** `deploy.sh` always creates a **new** contract instance. The old contract ID remains on-chain but is no longer referenced in your `.env`. + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `error: no such target: wasm32-unknown-unknown` | `rustup target add wasm32-unknown-unknown` | +| `stellar: command not found` | `cargo install stellar-cli` | +| `RESOURCE_EXHAUSTED` on deploy | Contract WASM too large; enable `opt-level = "z"` in `Cargo.toml` (already set) | +| `Already initialized` on invoke | Contract was already initialized. Deploy a new instance. | +| `Insufficient balance` | Run `./setup-testnet.sh` again or wait for Friendbot rate limit to reset. | diff --git a/contracts/contrib/scripts/deploy.sh b/contracts/contrib/scripts/deploy.sh new file mode 100755 index 00000000..e2581890 --- /dev/null +++ b/contracts/contrib/scripts/deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# deploy.sh — Compile the contrib contract to WASM and deploy to Stellar Testnet +# +# Steps: +# 1. Build the contract with `cargo build --target wasm32-unknown-unknown --release` +# 2. Deploy the WASM to Testnet via `stellar contract deploy` +# 3. Log the contract ID and persist it to .env +# +# Prerequisites: +# - Rust toolchain with wasm32-unknown-unknown target +# - stellar CLI (>= 22) +# - .env file with ADMIN_SECRET, NETWORK_PASSPHRASE, RPC_URL +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONTRACT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +WORKSPACE_ROOT="$(cd "$CONTRACT_ROOT/.." && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +# ── Colours ──────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } + +# ── Load .env ────────────────────────────────────────────────────────────────── +if [[ ! -f "$ENV_FILE" ]]; then + error "$ENV_FILE not found. Copy .env.example → .env and fill in values." +fi + +set -a +# shellcheck source=/dev/null +source "$ENV_FILE" +set +a + +: "${ADMIN_SECRET:?ADMIN_SECRET must be set in $ENV_FILE}" +: "${NETWORK_PASSPHRASE:?NETWORK_PASSPHRASE must be set in $ENV_FILE}" +: "${RPC_URL:?RPC_URL must be set in $ENV_FILE}" + +# ── Step 1: Build WASM ──────────────────────────────────────────────────────── +info "Building contrib contract to WASM…" + +( + cd "$WORKSPACE_ROOT" + cargo build --package contrib --target wasm32-unknown-unknown --release +) + +WASM_PATH="${WORKSPACE_ROOT}/target/wasm32-unknown-unknown/release/contrib.wasm" + +if [[ ! -f "$WASM_PATH" ]]; then + error "WASM not found at $WASM_PATH — build may have failed." +fi + +WASM_SIZE="$(du -h "$WASM_PATH" | cut -f1)" +info "WASM built: $WASM_PATH ($WASM_SIZE)" + +# ── Step 2: Deploy to Testnet ───────────────────────────────────────────────── +info "Deploying contract to Stellar Testnet…" + +DEPLOY_OUTPUT="$( + stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source-key "$ADMIN_SECRET" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + --ignore-checks +)" + +# stellar contract deploy prints the contract ID (e.g. C... or CA... ) +CONTRACT_ID="$(echo "$DEPLOY_OUTPUT" | tr -d '[:space:]')" + +if [[ -z "$CONTRACT_ID" ]]; then + error "Deployment output was empty. Check stellar CLI output above." +fi + +info "Contract deployed!" +info " Contract ID : $CONTRACT_ID" +info " RPC URL : $RPC_URL" + +# ── Step 3: Persist contract ID to .env ─────────────────────────────────────── +if grep -q "^CONTRACT_ID=" "$ENV_FILE"; then + sed -i.bak "s/^CONTRACT_ID=.*/CONTRACT_ID=${CONTRACT_ID}/" "$ENV_FILE" + rm -f "${ENV_FILE}.bak" +else + echo "" >> "$ENV_FILE" + echo "CONTRACT_ID=${CONTRACT_ID}" >> "$ENV_FILE" +fi + +info "Contract ID saved to $ENV_FILE" + +echo "" +echo "──────────────────────────────────────────────" +echo " Next step: initialize the contract" +echo " ./scripts/initialize.sh" +echo "──────────────────────────────────────────────" diff --git a/contracts/contrib/scripts/initialize.sh b/contracts/contrib/scripts/initialize.sh new file mode 100755 index 00000000..7b1f75a4 --- /dev/null +++ b/contracts/contrib/scripts/initialize.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# initialize.sh — Call the initialize() function on the deployed contrib contract +# +# The contract's initialize(env, admin) sets the admin address and initial state. +# This script derives the admin public address from ADMIN_SECRET and invokes +# the function via `stellar contract invoke`. +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +# ── Colours ──────────────────────────────────────────────────────────────────── +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } + +# ── Load .env ────────────────────────────────────────────────────────────────── +if [[ ! -f "$ENV_FILE" ]]; then + error "$ENV_FILE not found. Copy .env.example → .env and fill in values." +fi + +set -a +# shellcheck source=/dev/null +source "$ENV_FILE" +set +a + +: "${ADMIN_SECRET:?ADMIN_SECRET must be set in $ENV_FILE}" +: "${NETWORK_PASSPHRASE:?NETWORK_PASSPHRASE must be set in $ENV_FILE}" +: "${RPC_URL:?RPC_URL must be set in $ENV_FILE}" +: "${CONTRACT_ID:?CONTRACT_ID must be set in $ENV_FILE (run deploy.sh first)}" + +# ── Derive admin address ────────────────────────────────────────────────────── +info "Deriving admin address from ADMIN_SECRET…" + +ADMIN_ADDRESS="$(stellar keys address --secret-key "$ADMIN_SECRET" 2>/dev/null || \ + echo "$ADMIN_SECRET" | stellar keys address 2>/dev/null || \ + printf '%s' "$ADMIN_SECRET" | stellar keys address)" + +if [[ -z "$ADMIN_ADDRESS" ]]; then + error "Could not derive admin address from ADMIN_SECRET." +fi + +info "Admin address: $ADMIN_ADDRESS" + +# ── Invoke initialize ───────────────────────────────────────────────────────── +info "Calling initialize(admin: $ADMIN_ADDRESS) on contract $CONTRACT_ID…" + +stellar contract invoke \ + --id "$CONTRACT_ID" \ + --source-key "$ADMIN_SECRET" \ + --network-passphrase "$NETWORK_PASSPHRASE" \ + --rpc-url "$RPC_URL" \ + -- \ + initialize \ + --admin "$ADMIN_ADDRESS" + +info "Contract initialized successfully!" +info "" +info "You can verify by calling get_admin:" +info " stellar contract invoke \\" +info " --id $CONTRACT_ID \\" +info " --source-key \"\$ADMIN_SECRET\" \\" +info " --network-passphrase \"\$NETWORK_PASSPHRASE\" \\" +info " --rpc-url \"\$RPC_URL\" \\" +info " -- get_admin" diff --git a/contracts/contrib/scripts/setup-testnet.sh b/contracts/contrib/scripts/setup-testnet.sh new file mode 100755 index 00000000..024accb0 --- /dev/null +++ b/contracts/contrib/scripts/setup-testnet.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# setup-testnet.sh — Fund the admin account on Stellar Testnet via Friendbot +# +# Usage: +# ./setup-testnet.sh # reads ADMIN_SECRET from .env +# ./setup-testnet.sh # fund an arbitrary address +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" + +# ── Load .env ────────────────────────────────────────────────────────────────── +if [[ ! -f "$ENV_FILE" ]]; then + echo "ERROR: $ENV_FILE not found. Copy .env.example and fill in values." >&2 + exit 1 +fi + +set -a +# shellcheck source=/dev/null +source "$ENV_FILE" +set +a + +# ── Derive public key from secret ───────────────────────────────────────────── +if [[ -n "${1:-}" ]]; then + ADMIN_PUBLIC="$1" +else + if [[ -z "${ADMIN_SECRET:-}" ]]; then + echo "ERROR: ADMIN_SECRET is not set in $ENV_FILE" >&2 + exit 1 + fi + + ADMIN_PUBLIC="$(stellar keys address --secret-key "$ADMIN_SECRET" 2>/dev/null || \ + echo "$ADMIN_SECRET" | stellar keys address 2>/dev/null || \ + printf '%s' "$ADMIN_SECRET" | stellar keys address)" + + if [[ -z "$ADMIN_PUBLIC" ]]; then + echo "ERROR: Could not derive public key from ADMIN_SECRET" >&2 + exit 1 + fi +fi + +echo "Funding account: $ADMIN_PUBLIC" + +# ── Call Friendbot ───────────────────────────────────────────────────────────── +RESPONSE=$(curl -sS "https://friendbot.stellar.org/?addr=${ADMIN_PUBLIC}") || { + echo "ERROR: Failed to reach Friendbot" >&2 + exit 1 +} + +# Friendbot returns the transaction envelope on success or JSON error +if echo "$RESPONSE" | grep -q '"hash"'; then + echo "Account funded successfully!" + echo " Address : $ADMIN_PUBLIC" + echo " Tx hash : $(echo "$RESPONSE" | grep -o '"hash":"[^"]*"' | cut -d'"' -f4)" +else + # Already funded accounts return a friendly message + if echo "$RESPONSE" | grep -qi "already"; then + echo "Account already funded: $ADMIN_PUBLIC" + else + echo "WARNING: Unexpected Friendbot response:" >&2 + echo "$RESPONSE" >&2 + fi +fi + +echo "" +echo "Verify on Testnet explorer:" +echo " https://stellar.expert/explorer/testnet/account/$ADMIN_PUBLIC" diff --git a/frontend/contrib/app/(dashboard)/assets/page.tsx b/frontend/contrib/app/(dashboard)/assets/page.tsx index b3c33b74..2d732431 100644 --- a/frontend/contrib/app/(dashboard)/assets/page.tsx +++ b/frontend/contrib/app/(dashboard)/assets/page.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { Plus, Eye, Pencil, Trash2 } from "lucide-react"; +import { Plus, Eye, Pencil, Trash2, List, ArrowDown } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { StatusBadge } from "@/components/assets/status-badge"; @@ -13,10 +13,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { queryKeys } from "@/lib/query/keys"; import { AssetFilterBar, AssetFilters } from "@/contrib/components/assets/AssetFilterBar"; import { CreateAssetModal } from "@/contrib/components/assets/CreateAssetModal"; +import { InfiniteAssetList } from "@/contrib/components/assets/InfiniteAssetList"; import { ThemeToggle } from "@/contrib/components/ui/theme-toggle"; const LIMIT = 20; +type ViewMode = "paginated" | "infinite"; + function SkeletonRow() { return ( @@ -38,6 +41,7 @@ export default function AssetsContribPage() { const [showModal, setShowModal] = useState(false); const [deleteId, setDeleteId] = useState(null); const [editId, setEditId] = useState(null); + const [viewMode, setViewMode] = useState("paginated"); const [filters, setFilters] = useState({ search: searchParams.get("search") ?? "", @@ -66,7 +70,6 @@ export default function AssetsContribPage() { const handleDelete = async () => { if (!deleteId) return; await deleteAsset.mutateAsync(); - // optimistic: already removed from cache by mutation setDeleteId(null); }; @@ -75,6 +78,10 @@ export default function AssetsContribPage() { setPage(1); }; + const toggleViewMode = () => { + setViewMode((prev: ViewMode) => (prev === "paginated" ? "infinite" : "paginated")); + }; + return (
{/* Header */} @@ -86,6 +93,25 @@ export default function AssetsContribPage() {

+ {/* View mode toggle */} +
- {/* Filter Bar */} - - - {/* Table */} -
-
- - - - {["Asset ID", "Name", "Category", "Department", "Status", "Condition", "Actions"].map( - (h) => ( - - ) - )} - - - - {isLoading ? ( - Array.from({ length: 5 }).map((_, i) => ) - ) : assets.length === 0 ? ( - - - - ) : ( - assets.map((asset) => ( - - - - - - - - - - )) - )} - -
- {h} -
- {Object.values(filters).some(Boolean) - ? "No assets match your filters." - : 'No assets yet. Click "Create Asset" to get started.'} -
{asset.assetId}{asset.name}{asset.category?.name ?? "—"}{asset.department?.name ?? "—"} - - - - -
- - - -
-
-
+ {/* Content area - conditionally render based on view mode */} + {viewMode === "infinite" ? ( + setEditId(id)} + onDelete={(id) => setDeleteId(id)} + /> + ) : ( + <> + {/* Filter Bar */} + - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {page} of {totalPages} — {total} total -

-
- - + {/* Table */} +
+
+ + + + {["Asset ID", "Name", "Category", "Department", "Status", "Condition", "Actions"].map( + (h) => ( + + ) + )} + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ) + ) : assets.length === 0 ? ( + + + + ) : ( + assets.map((asset) => ( + + + + + + + + + + )) + )} + +
+ {h} +
+ {Object.values(filters).some(Boolean) + ? "No assets match your filters." + : 'No assets yet. Click "Create Asset" to get started.'} +
{asset.assetId}{asset.name}{asset.category?.name ?? "—"}{asset.department?.name ?? "—"} + + + + +
+ + + +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {page} of {totalPages} — {total} total +

+
+ + +
+
+ )}
- )} -
+ + )} {/* Create Modal */} {showModal && ( diff --git a/frontend/contrib/components/assets/InfiniteAssetList.tsx b/frontend/contrib/components/assets/InfiniteAssetList.tsx new file mode 100644 index 00000000..79b7d3a7 --- /dev/null +++ b/frontend/contrib/components/assets/InfiniteAssetList.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { Eye, Pencil, Trash2, Loader2 } from "lucide-react"; +import { StatusBadge } from "@/contrib/components/assets/status-badge"; +import { ConditionBadge } from "@/contrib/components/assets/condition-badge"; +import { useInfiniteAssets } from "@/contrib/hooks/useInfiniteAssets"; +import { AssetFilterBar, AssetFilters } from "@/contrib/components/assets/AssetFilterBar"; +import type { Asset } from "@/lib/query/types/asset"; + +interface InfiniteAssetListProps { + filters: AssetFilters; + onFilterChange: (filters: AssetFilters) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +} + +export function InfiniteAssetList({ + filters, + onFilterChange, + onEdit, + onDelete, +}: InfiniteAssetListProps) { + const router = useRouter(); + + const { + data, + isLoading, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = useInfiniteAssets({ + filters: { + search: filters.search || undefined, + status: filters.status || undefined, + condition: filters.condition || undefined, + departmentId: filters.departmentId || undefined, + categoryId: filters.categoryId || undefined, + }, + limit: 20, + }); + + const observerRef = useRef(null); + + const sentinelRef = useCallback( + (node: HTMLDivElement | null) => { + if (observerRef.current) observerRef.current.disconnect(); + if (!node || !hasNextPage || isFetchingNextPage) return; + + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { rootMargin: "200px" }, + ); + + observerRef.current.observe(node); + }, + [hasNextPage, isFetchingNextPage, fetchNextPage], + ); + + const allAssets = data?.pages.flatMap((page: { data: Asset[] }) => page.data) ?? []; + const total = data?.pages[0]?.total ?? 0; + + return ( +
+ + +
+
+ + + + {["Asset ID", "Name", "Category", "Department", "Status", "Condition", "Actions"].map( + (h) => ( + + ) + )} + + + + {isLoading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + {Array.from({ length: 7 }).map((_, j) => ( + + ))} + + )) + ) : allAssets.length === 0 ? ( + + + + ) : ( + allAssets.map((asset) => ( + + + + + + + + + + )) + )} + +
+ {h} +
+
+
+ {Object.values(filters).some(Boolean) + ? "No assets match your filters." + : 'No assets yet. Click "Create Asset" to get started.'} +
{asset.assetId}{asset.name}{asset.category?.name ?? "—"}{asset.department?.name ?? "—"} + + + + +
+ + + +
+
+
+ + {/* Infinite scroll sentinel & status area */} + {allAssets.length > 0 && ( +
+ {isFetchingNextPage ? ( +
+ + Loading more assets... +
+ ) : !hasNextPage ? ( +

No more assets

+ ) : null} + {/* IntersectionObserver sentinel */} +
+
+ )} +
+
+ ); +} diff --git a/frontend/contrib/hooks/index.ts b/frontend/contrib/hooks/index.ts index 03278762..3a55e616 100644 --- a/frontend/contrib/hooks/index.ts +++ b/frontend/contrib/hooks/index.ts @@ -1,2 +1,3 @@ export { useAssetWebSocket } from './useAssetWebSocket'; export { useCommandPalette } from './useCommandPalette'; +export { useInfiniteAssets } from './useInfiniteAssets'; diff --git a/frontend/contrib/hooks/useInfiniteAssets.ts b/frontend/contrib/hooks/useInfiniteAssets.ts new file mode 100644 index 00000000..a75a14ce --- /dev/null +++ b/frontend/contrib/hooks/useInfiniteAssets.ts @@ -0,0 +1,34 @@ +"use client"; + +import { useInfiniteQuery, UseInfiniteQueryOptions } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/query/keys"; +import { assetApiClient, AssetListFilters, AssetListResponse } from "@/lib/api/assets"; + +interface UseInfiniteAssetsOptions { + filters?: Omit; + limit?: number; +} + +export function useInfiniteAssets({ filters, limit = 20 }: UseInfiniteAssetsOptions = {}) { + const queryKey = [...queryKeys.assets.list((filters as Record) ?? {}), "infinite"] as const; + + return useInfiniteQuery({ + queryKey, + queryFn: ({ pageParam }: { pageParam: number }) => + assetApiClient.getAssets({ ...filters, page: pageParam, limit }), + initialPageParam: 1, + getNextPageParam: (lastPage: AssetListResponse) => { + const totalPages = Math.ceil(lastPage.total / limit); + if (lastPage.page < totalPages) { + return lastPage.page + 1; + } + return undefined; + }, + } satisfies UseInfiniteQueryOptions< + AssetListResponse, + Error, + AssetListResponse, + typeof queryKey, + number + >); +}