diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 2ef92f95..89ecb5fb 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1277,6 +1277,23 @@ impl Escrow { env.storage().instance().get(&DataKey::JobCount).unwrap_or(0) } + pub fn get_jobs_batch(env: Env, start: u64, limit: u32) -> Vec { + let count: u64 = env.storage().instance().get(&DataKey::JobCount).unwrap_or(0); + let mut jobs = Vec::new(&env); + if start == 0 || limit == 0 || start > count { + return jobs; + } + let end = core::cmp::min(count, start.saturating_add(limit as u64).saturating_sub(1)); + let mut cursor = start; + while cursor <= end { + if let Some(job) = env.storage().persistent().get::<_, Job>(&DataKey::Job(cursor)) { + jobs.push_back(job); + } + cursor = cursor.saturating_add(1); + } + jobs + } + pub fn get_completed_jobs_count(env: Env) -> u64 { env.storage().instance().get(&DataKey::CompletedJobsCount).unwrap_or(0) } diff --git a/frontend/app/compare/page.tsx b/frontend/app/compare/page.tsx index 0bfb4079..3bea14b3 100644 --- a/frontend/app/compare/page.tsx +++ b/frontend/app/compare/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { getJob } from "@/lib/contract"; +import { getJob, getJobsBatch } from "@/lib/contract"; import { formatDeadline, toXlm } from "@/lib/format"; import type { Job } from "@/lib/types"; import Link from "next/link"; @@ -64,20 +64,31 @@ export default function ComparePage() { setLoading(true); setError(null); - Promise.all( - ids.map(async (id) => { - const job = await getJob(String(id)); - return job ? { id, job } : null; - }), - ) - .then((results) => { - const valid = results.filter((r): r is JobEntry => r !== null); - setEntries(valid); - }) - .catch((e) => { + (async () => { + try { + const validEntries: JobEntry[] = []; + if (ids.length > 0) { + const maxId = Math.max(...ids); + const minId = Math.min(...ids); + const jobs = await getJobsBatch(minId, maxId - minId + 1); + const jobMap = new Map(); + jobs.forEach((job, idx) => { + jobMap.set(minId + idx, job); + }); + for (const id of ids) { + const job = jobMap.get(id); + if (job) { + validEntries.push({ id, job }); + } + } + } + setEntries(validEntries); + } catch (e) { setError(e instanceof Error ? e.message : "Failed to load jobs for comparison."); - }) - .finally(() => setLoading(false)); + } finally { + setLoading(false); + } + })(); }, [searchParams]); const ids = searchParams.get("ids"); diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 8dd162a2..67d6f21b 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -8,6 +8,7 @@ import { getJob, getJobCount, getCompletedJobsCount, + getJobsBatch, submitWork, enforceDeadline, } from "@/lib/contract"; @@ -107,10 +108,13 @@ export default function DashboardPage() { try { const count = await getJobCount(); const fetched: Array<{ id: number; job: Job }> = []; - for (let id = 1; id <= count; id += 1) { - const job = await getJob(String(id)); - if (job && (job.client === wallet || job.freelancer === wallet)) { - fetched.push({ id, job }); + if (count > 0) { + const jobs = await getJobsBatch(1, count); + for (let id = 1; id <= jobs.length; id++) { + const job = jobs[id - 1]; + if (job && (job.client === wallet || job.freelancer === wallet)) { + fetched.push({ id, job }); + } } } setAllJobs(fetched); @@ -150,16 +154,17 @@ export default function DashboardPage() { } setBookmarkedLoading(true); try { - const results = await Promise.all( - ids.map(async (id) => { - try { - const job = await getJob(String(id)); - return job ? { id, job } : null; - } catch { - return null; - } - }), - ); + const maxId = Math.max(...ids); + const minId = Math.min(...ids); + const jobs = await getJobsBatch(minId, maxId - minId + 1); + const jobMap = new Map(); + jobs.forEach((job, idx) => { + jobMap.set(minId + idx, job); + }); + const results = ids.map((id) => { + const job = jobMap.get(id); + return job ? { id, job } : null; + }); setBookmarkedJobs( results.filter( (item): item is { id: number; job: Job } => item !== null, diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 0ede2732..45b009eb 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google"; import { NextIntlClientProvider } from "next-intl"; import { getLocale, getMessages } from "next-intl/server"; import dynamic from "next/dynamic"; +import ClientComponents from "@/components/ClientComponents"; import { WalletProvider } from "@/lib/wallet-context"; import { ToastProvider } from "@/components/ToastProvider"; import { NotificationProvider } from "@/lib/notifications-context"; @@ -22,9 +23,6 @@ import "./globals.css"; const CommandPalette = dynamic(() => import("@/components/CommandPalette"), { ssr: false }); const ShortcutCheatSheet = dynamic(() => import("@/components/ShortcutCheatSheet"), { ssr: false }); const OnboardingProvider = dynamic(() => import("@/components/OnboardingProvider"), { ssr: false }); -const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false }); -const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false }); -const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false }); const geistSans = Geist({ variable: "--font-geist-sans", @@ -150,8 +148,7 @@ export default async function RootLayout({ > Skip to main content - - + @@ -187,15 +184,14 @@ export default async function RootLayout({ - - + diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ff5c6a88..0fc006b0 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -8,7 +8,7 @@ import JobCardSkeleton from "@/components/JobCardSkeleton"; import SectionCard from "@/components/SectionCard"; import ComparisonBar from "@/components/ComparisonBar"; import JobFilterPanel, { DEFAULT_FILTERS, type JobFilters } from "@/components/JobFilterPanel"; -import { acceptJob, getDescriptionCid, getJob, getJobCount } from "@/lib/contract"; +import { acceptJob, getDescriptionCid, getJob, getJobCount, getJobsBatch } from "@/lib/contract"; import { fetchFromIpfs } from "@/lib/ipfs-service"; import { useNotifications } from "@/lib/notifications-context"; import { @@ -272,21 +272,11 @@ export default function HomePage() { } } - const idsToFetch: string[] = []; - for (let id = 1; id <= count; id += 1) { - idsToFetch.push(String(id)); - } - - const results = await Promise.all( - idsToFetch.map(async (id) => { - try { - const job = await getJob(id); - return job ? { id: Number(id), job } : null; - } catch { - return null; - } - }), - ); + const fetchedJobs = await getJobsBatch(1, count); + const results = fetchedJobs.map((job, index) => ({ + id: index + 1, + job, + })); const fetched = results.filter( (item): item is { id: number; job: Job } => diff --git a/frontend/app/profile/[address]/profile-page-client.tsx b/frontend/app/profile/[address]/profile-page-client.tsx index d8951da0..d44070d5 100644 --- a/frontend/app/profile/[address]/profile-page-client.tsx +++ b/frontend/app/profile/[address]/profile-page-client.tsx @@ -2,7 +2,7 @@ import ErrorBanner from "@/components/ErrorBanner"; import StatusPill from "@/components/StatusPill"; -import { getJob, getJobCount, isBlacklisted, isWhitelisted, isWhitelistModeEnabled } from "@/lib/contract"; +import { getJob, getJobCount, getJobsBatch, isBlacklisted, isWhitelisted, isWhitelistModeEnabled } from "@/lib/contract"; import { toXlm } from "@/lib/format"; import { MAX_BIO_LENGTH, @@ -347,11 +347,14 @@ export default function ProfilePageClient({ address }: { address: string }) { const count = await getJobCount(); const fetched: ProfileJob[] = []; - for (let id = 1; id <= count; id += 1) { - const job = await getJob(String(id)); - if (!job) continue; - if (job.client === address) fetched.push({ id, job, role: "client" }); - else if (job.freelancer === address) fetched.push({ id, job, role: "freelancer" }); + if (count > 0) { + const jobs = await getJobsBatch(1, count); + for (let id = 1; id <= jobs.length; id++) { + const job = jobs[id - 1]; + if (!job) continue; + if (job.client === address) fetched.push({ id, job, role: "client" }); + else if (job.freelancer === address) fetched.push({ id, job, role: "freelancer" }); + } } setJobs(fetched); } catch (e) { diff --git a/frontend/app/transactions/page.tsx b/frontend/app/transactions/page.tsx index 91468840..0e9f25bf 100644 --- a/frontend/app/transactions/page.tsx +++ b/frontend/app/transactions/page.tsx @@ -5,7 +5,7 @@ import ErrorBanner from "@/components/ErrorBanner"; import NoResultsState from "@/components/NoResultsState"; import SectionCard from "@/components/SectionCard"; import TransactionRowSkeleton from "@/components/TransactionRowSkeleton"; -import { getJob, getJobCount } from "@/lib/contract"; +import { getJob, getJobCount, getJobsBatch } from "@/lib/contract"; import { useWallet } from "@/lib/wallet-context"; import { ALL_TX_TYPES, @@ -101,10 +101,13 @@ export default function TransactionsPage() { try { const count = await getJobCount(); const results: Array<{ id: number; job: Job }> = []; - for (let id = 1; id <= count; id++) { - const job = await getJob(String(id)); - if (job && (job.client === wallet || job.freelancer === wallet)) { - results.push({ id, job }); + if (count > 0) { + const jobs = await getJobsBatch(1, count); + for (let id = 1; id <= jobs.length; id++) { + const job = jobs[id - 1]; + if (job && (job.client === wallet || job.freelancer === wallet)) { + results.push({ id, job }); + } } } setAllJobs(results); diff --git a/frontend/components/ClientComponents.tsx b/frontend/components/ClientComponents.tsx new file mode 100644 index 00000000..f35cb6d8 --- /dev/null +++ b/frontend/components/ClientComponents.tsx @@ -0,0 +1,17 @@ +"use client"; + +import dynamic from "next/dynamic"; + +const AnnouncementBanner = dynamic(() => import("@/components/AnnouncementBanner"), { ssr: false }); +const InstallPrompt = dynamic(() => import("@/components/InstallPrompt"), { ssr: false }); +const ServiceWorkerRegistration = dynamic(() => import("@/components/ServiceWorkerRegistration"), { ssr: false }); + +export default function ClientComponents() { + return ( + <> + + + + + ); +} diff --git a/frontend/components/ToastProvider.tsx b/frontend/components/ToastProvider.tsx index 8bb22cee..37076e94 100644 --- a/frontend/components/ToastProvider.tsx +++ b/frontend/components/ToastProvider.tsx @@ -124,6 +124,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { ))} +
{ return (response.data as Job) ?? null; } +export async function getJobsBatch(startId: number, limit: number): Promise { + const response = await callContract( + getActiveContractId(), + "get_jobs_batch", + [ + nativeToScVal(String(startId), { type: "u64" }), + nativeToScVal(limit, { type: "u32" }), + ], + { readOnly: true }, + ); + return (response.data as Job[]) ?? []; +} + export async function getJobCount(): Promise { const response = await callContract( getActiveContractId(), diff --git a/frontend/lib/disputes-loader.ts b/frontend/lib/disputes-loader.ts index 60f67805..e4d78446 100644 --- a/frontend/lib/disputes-loader.ts +++ b/frontend/lib/disputes-loader.ts @@ -38,15 +38,16 @@ export type DisputesPageData = { }; export async function loadDisputesPageData(wallet: string): Promise { - const { getJobCount, getJob } = await import("@/lib/contract"); + const { getJobCount, getJobsBatch } = await import("@/lib/contract"); const count = await getJobCount(); const disputes: Dispute[] = []; const eligibleJobs: EligibleJob[] = []; const now = new Date().toISOString(); - for (let id = 1; id <= count; id++) { - const job = await getJob(String(id)); + const allJobs = count > 0 ? await getJobsBatch(1, count) : []; + for (let id = 1; id <= allJobs.length; id++) { + const job = allJobs[id - 1]; if (!job) continue; if (job.client !== wallet && job.freelancer !== wallet) continue; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 704b38d3..3c66a208 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20,6 +20,7 @@ "@types/dompurify": "3.0.5", "@walletconnect/web3wallet": "^1.11.0", "dompurify": "3.4.11", + "lucide-react": "^1.28.0", "next": "16.2.3", "next-intl": "^4.13.0", "react": "19.2.4", @@ -8025,6 +8026,15 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.28.0.tgz", + "integrity": "sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "dev": true, @@ -14064,6 +14074,111 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", + "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", + "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", + "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", + "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", + "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", + "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", + "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/frontend/package.json b/frontend/package.json index ec5815fd..545edc3b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@types/dompurify": "3.0.5", "@walletconnect/web3wallet": "^1.11.0", "dompurify": "3.4.11", + "lucide-react": "^1.28.0", "next": "16.2.3", "next-intl": "^4.13.0", "react": "19.2.4",