Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/pr-target-check.yml
Original file line number Diff line number Diff line change
@@ -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'
});

69 changes: 61 additions & 8 deletions app/marketplace/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | 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 (
<main className="container mx-auto px-4 py-8">
Expand All @@ -136,16 +167,38 @@ export default function MarketplacePage() {
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Invoice Marketplace</h1>
<div className="space-y-4">
{invoices.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} />
))}

<MarketplaceFilterBar
status={status}
search={search}
onStatusChange={setStatus}
onSearchChange={handleSearchChange}
onClear={handleClear}
/>

{isFetching && !isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground my-4">
<Loader2 className="size-3 animate-spin" />
Refreshing…
</div>
)}

<div className="space-y-4 mt-4">
{filtered.length === 0 ? (
<p className="py-12 text-center text-muted-foreground">
No invoices match your filters.
</p>
) : (
filtered.map((invoice) => (
<InvoiceRow key={invoice.id} invoice={invoice} />
))
)}
{isFetchingNextPage &&
Array.from({ length: 3 }).map((_, i) => (
<SkeletonRow key={`skeleton-${i}`} />
))}
{hasNextPage && <div ref={sentinelRefCallback} className="h-4" />}
{!hasNextPage && invoices.length > 0 && (
{!hasNextPage && filtered.length > 0 && (
<p className="text-center text-sm text-muted-foreground py-4">
No more invoices
</p>
Expand Down
4 changes: 4 additions & 0 deletions components/invoices/InvoiceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
Expand All @@ -108,6 +111,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
</Badge>
</div>
<p className="text-sm text-muted-foreground">Seller: {invoice.seller}</p>
<CountdownTimer deadline={invoice.due_date} published={published} />
</CardHeader>
</Card>

Expand Down
53 changes: 53 additions & 0 deletions components/marketplace/countdown-timer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Badge variant="destructive" className="text-xs">
Expired
</Badge>
);
}

return (
<span className="font-mono text-sm tabular-nums text-muted-foreground">
{formatCountdown(remaining)}
</span>
);
}

export function isExpired(deadline: string | null): boolean {
if (!deadline) return false;
return new Date(deadline).getTime() <= Date.now();
}
68 changes: 68 additions & 0 deletions components/marketplace/filter-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select
value={status}
onValueChange={(v) => onStatusChange(v as InvoiceStatus | "all")}
>
<SelectTrigger className="w-full sm:w-[160px]">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="funded">Funded</SelectItem>
<SelectItem value="settled">Settled</SelectItem>
</SelectContent>
</Select>

<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search by title…"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9"
/>
</div>

{hasFilters && (
<Button variant="ghost" size="sm" onClick={onClear}>
<X className="size-4" />
Clear filters
</Button>
)}
</div>
);
}
4 changes: 3 additions & 1 deletion components/marketplace/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export {};
export { MarketplaceFilterBar } from "./filter-bar";
export { InvoiceCard } from "./invoice-card";
export { CountdownTimer, isExpired } from "./countdown-timer";
53 changes: 53 additions & 0 deletions components/marketplace/invoice-card.tsx
Original file line number Diff line number Diff line change
@@ -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<string, "default" | "secondary" | "outline" | "destructive"> = {
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 (
<Card className="flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="line-clamp-1 text-base">
{invoice.title}
</CardTitle>
<Badge variant={statusVariant[invoice.status]}>
{invoice.status}
</Badge>
</div>
</CardHeader>

<CardContent className="flex flex-1 flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-lg font-semibold">
{invoice.amount.toLocaleString()} XLM
</span>
<CountdownTimer deadline={invoice.due_date} published={published} />
</div>

<Button
className="mt-auto w-full"
disabled={!published || expired}
onClick={() => onInvest?.(invoice.id)}
>
{expired ? "Expired" : "Invest"}
</Button>
</CardContent>
</Card>
);
}
8 changes: 7 additions & 1 deletion components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -13,5 +14,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
})
);

return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
return (
<QueryClientProvider client={queryClient}>
{children}
<Toaster position="top-right" />
</QueryClientProvider>
);
}
Loading