diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml new file mode 100644 index 0000000..87fa547 --- /dev/null +++ b/.github/workflows/pr-target-check.yml @@ -0,0 +1,37 @@ +name: PR Target Check +on: + pull_request_target: + branches: + - main +jobs: + warn-wrong-target: + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Comment and close PR targeting main + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `👋 Hey @${context.payload.pull_request.user.login}! Thanks for your contribution. + +It looks like this PR targets the \`main\` branch. We only accept PRs targeting the \`dev\` branch — please reopen your PR against \`dev\` instead. + +Steps to fix: +1. Close this PR +2. Change the base branch to \`dev\` +3. Reopen the PR + +Thanks! 🚀` + }); + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed' + }); + diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index 8c9cf14..c727b8d 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -1,11 +1,13 @@ "use client"; -import { useRef, useCallback, useMemo } from "react"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { useCallback, useMemo, useRef, useState } from "react"; +import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { fetchInvoices, type Invoice } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { MarketplaceFilterBar } from "@/components/marketplace"; +import { Loader2 } from "lucide-react"; function InvoiceRow({ invoice }: { invoice: Invoice }) { return ( @@ -80,12 +82,14 @@ export default function MarketplacePage() { hasNextPage, isFetchingNextPage, isLoading, + isFetching, } = useInfiniteQuery({ queryKey: ["invoices"], queryFn: ({ pageParam }) => fetchInvoices(pageParam as string | undefined), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + staleTime: 60 * 1000, }); const sentinelRef = useRef(null); @@ -115,11 +119,38 @@ export default function MarketplacePage() { [observer] ); - const invoices = useMemo( + const allInvoices = useMemo( () => data?.pages.flatMap((p) => p.invoices) ?? [], [data] ); + const [status, setStatus] = useState<"open" | "funded" | "settled" | "all">("all"); + const [search, setSearch] = useState(""); + const debounceRef = useRef | null>(null); + const [debouncedSearch, setDebouncedSearch] = useState(""); + + const handleSearchChange = useCallback((value: string) => { + setSearch(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => setDebouncedSearch(value), 300); + }, []); + + const handleClear = useCallback(() => { + setStatus("all"); + setSearch(""); + setDebouncedSearch(""); + }, []); + + const filtered = useMemo(() => { + return allInvoices.filter((inv) => { + const matchesStatus = status === "all" || inv.status === status; + const matchesSearch = + debouncedSearch === "" || + inv.title.toLowerCase().includes(debouncedSearch.toLowerCase()); + return matchesStatus && matchesSearch; + }); + }, [allInvoices, status, debouncedSearch]); + if (isLoading) { return (
@@ -136,16 +167,38 @@ export default function MarketplacePage() { return (

Invoice Marketplace

-
- {invoices.map((invoice) => ( - - ))} + + + + {isFetching && !isLoading && ( +
+ + Refreshing… +
+ )} + +
+ {filtered.length === 0 ? ( +

+ No invoices match your filters. +

+ ) : ( + filtered.map((invoice) => ( + + )) + )} {isFetchingNextPage && Array.from({ length: 3 }).map((_, i) => ( ))} {hasNextPage &&
} - {!hasNextPage && invoices.length > 0 && ( + {!hasNextPage && filtered.length > 0 && (

No more invoices

diff --git a/components/invoices/InvoiceDetail.tsx b/components/invoices/InvoiceDetail.tsx index 8968ffb..1c2c144 100644 --- a/components/invoices/InvoiceDetail.tsx +++ b/components/invoices/InvoiceDetail.tsx @@ -6,6 +6,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; +import { CountdownTimer, isExpired } from "@/components/marketplace"; function InvoiceDetailSkeleton() { return ( @@ -96,6 +97,8 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) { } const progress = Math.min((invoice.raised / invoice.amount) * 100, 100); + const published = invoice.status === "open"; + const expired = isExpired(invoice.due_date); return (
@@ -108,6 +111,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {

Seller: {invoice.seller}

+ diff --git a/components/marketplace/countdown-timer.tsx b/components/marketplace/countdown-timer.tsx new file mode 100644 index 0000000..62fd90e --- /dev/null +++ b/components/marketplace/countdown-timer.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { differenceInSeconds } from "date-fns"; +import { Badge } from "@/components/ui/badge"; + +interface CountdownTimerProps { + deadline: string | null; + published: boolean; +} + +function formatCountdown(totalSeconds: number): string { + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + + return `${String(days).padStart(2, "0")}:${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`; +} + +export function CountdownTimer({ deadline, published }: CountdownTimerProps) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!deadline || !published) return; + + const id = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(id); + }, [deadline, published]); + + if (!published || !deadline) return null; + + const deadlineMs = new Date(deadline).getTime(); + const remaining = differenceInSeconds(deadlineMs, now); + + if (remaining <= 0) { + return ( + + Expired + + ); + } + + return ( + + {formatCountdown(remaining)} + + ); +} + +export function isExpired(deadline: string | null): boolean { + if (!deadline) return false; + return new Date(deadline).getTime() <= Date.now(); +} diff --git a/components/marketplace/filter-bar.tsx b/components/marketplace/filter-bar.tsx new file mode 100644 index 0000000..5cba1c8 --- /dev/null +++ b/components/marketplace/filter-bar.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { Search, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type InvoiceStatus = "open" | "funded" | "settled"; + +interface MarketplaceFilterBarProps { + status: InvoiceStatus | "all"; + search: string; + onStatusChange: (value: InvoiceStatus | "all") => void; + onSearchChange: (value: string) => void; + onClear: () => void; +} + +export function MarketplaceFilterBar({ + status, + search, + onStatusChange, + onSearchChange, + onClear, +}: MarketplaceFilterBarProps) { + const hasFilters = status !== "all" || search.length > 0; + + return ( +
+ + +
+ + onSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {hasFilters && ( + + )} +
+ ); +} diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts index cb0ff5c..eda6b4b 100644 --- a/components/marketplace/index.ts +++ b/components/marketplace/index.ts @@ -1 +1,3 @@ -export {}; +export { MarketplaceFilterBar } from "./filter-bar"; +export { InvoiceCard } from "./invoice-card"; +export { CountdownTimer, isExpired } from "./countdown-timer"; diff --git a/components/marketplace/invoice-card.tsx b/components/marketplace/invoice-card.tsx new file mode 100644 index 0000000..b2850e8 --- /dev/null +++ b/components/marketplace/invoice-card.tsx @@ -0,0 +1,53 @@ +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { CountdownTimer, isExpired } from "./countdown-timer"; +import type { Invoice } from "@/lib/api"; + +const statusVariant: Record = { + open: "default", + funded: "secondary", + settled: "outline", +}; + +interface InvoiceCardProps { + invoice: Invoice; + onInvest?: (invoiceId: string) => void; +} + +export function InvoiceCard({ invoice, onInvest }: InvoiceCardProps) { + const published = invoice.status === "open"; + const expired = isExpired(invoice.due_date); + + return ( + + +
+ + {invoice.title} + + + {invoice.status} + +
+
+ + +
+ + {invoice.amount.toLocaleString()} XLM + + +
+ + +
+
+ ); +} diff --git a/components/providers.tsx b/components/providers.tsx index 3ddbe84..7709e76 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useState } from "react"; +import { Toaster } from "@/components/ui/sonner"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -13,5 +14,10 @@ export function Providers({ children }: { children: React.ReactNode }) { }) ); - return {children}; + return ( + + {children} + + + ); } diff --git a/components/ui/select.tsx b/components/ui/select.tsx new file mode 100644 index 0000000..c0dc712 --- /dev/null +++ b/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/hooks/useInvestments.ts b/hooks/useInvestments.ts index 915c72a..b6b1342 100644 --- a/hooks/useInvestments.ts +++ b/hooks/useInvestments.ts @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { investInInvoice } from "@/lib/api"; import type { InvoiceDetail } from "@/lib/api"; import { toast } from "sonner"; +import { INVOICES_QUERY_KEY } from "./useInvoices"; interface InvestMutationVars { invoiceId: string; @@ -42,6 +43,7 @@ export function useInvestMutation() { onSettled: (_data, _error, { invoiceId }) => { queryClient.invalidateQueries({ queryKey: ["invoice", invoiceId] }); + queryClient.invalidateQueries({ queryKey: INVOICES_QUERY_KEY }); }, }); } diff --git a/hooks/useInvoices.ts b/hooks/useInvoices.ts index ba773e6..cbd67ec 100644 --- a/hooks/useInvoices.ts +++ b/hooks/useInvoices.ts @@ -3,12 +3,16 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { fetchInvoices, type InvoicesResponse } from "@/lib/api"; +export const INVOICES_QUERY_KEY = ["invoices"] as const; +export const STALE_TIME = 60 * 1000; + export function useInvoices() { return useInfiniteQuery({ - queryKey: ["invoices"], + queryKey: INVOICES_QUERY_KEY, queryFn: ({ pageParam }) => fetchInvoices(pageParam as string | undefined), initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.has_more ? lastPage.next_cursor ?? undefined : undefined, + staleTime: STALE_TIME, }); }