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
94 changes: 94 additions & 0 deletions components/invoices/DocumentPreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { ErrorBoundary } from "@/components/invoices/ErrorBoundary";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";

interface DocumentPreviewProps {
documentUrl: string | null;
}

function PreviewFallback({ documentUrl, onRetry }: { documentUrl: string; onRetry: () => void }) {
return (
<div className="h-48 w-full rounded-md border bg-muted flex flex-col items-center justify-center gap-3 p-4">
<p className="text-sm text-muted-foreground text-center">
Document preview unavailable
</p>
<div className="flex items-center gap-2">
<a
href={documentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
Download directly
</a>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
</div>
);
}

function DocumentPreviewContent({ documentUrl }: { documentUrl: string }) {
const { data, isLoading, isError } = useQuery({
queryKey: ["document-preview", documentUrl],
queryFn: async () => {
const res = await fetch(documentUrl);
if (!res.ok) throw new Error(`Failed to fetch document: ${res.status}`);
return res.blob();
},
staleTime: 5 * 60 * 1000,
retry: 1,
});

if (isLoading) {
return (
<div className="h-48 w-full rounded-md border bg-muted flex items-center justify-center">
<Skeleton className="h-40 w-full" />
</div>
);
}

if (isError || !data) {
throw new Error("Failed to load document preview");
}

const objectUrl = URL.createObjectURL(data);

return (
<div className="h-48 w-full rounded-md border bg-muted flex items-center justify-center">
<a
href={objectUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
onClick={() => {
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}}
>
View Document
</a>
</div>
);
}

export function DocumentPreview({ documentUrl }: DocumentPreviewProps) {
if (!documentUrl) {
return (
<p className="text-sm text-muted-foreground text-center py-8">
No document attached
</p>
);
}

return (
<ErrorBoundary fallback={(retry: () => void) => (
<PreviewFallback documentUrl={documentUrl} onRetry={retry} />
)}>
<DocumentPreviewContent documentUrl={documentUrl} />
</ErrorBoundary>
);
}
60 changes: 60 additions & 0 deletions components/invoices/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

import React, { Component, type ReactNode } from "react";
import { logError } from "@/lib/logger";
import { Button } from "@/components/ui/button";

interface ErrorBoundaryProps {
children: ReactNode;
fallback?: ReactNode | ((retry: () => void) => ReactNode);
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}

interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
logError("Error boundary caught an error", { error, errorInfo });
this.props.onError?.(error, errorInfo);
}

handleRetry = (): void => {
this.setState({ hasError: false, error: null });
};

render(): ReactNode {
if (this.state.hasError) {
if (this.props.fallback) {
if (typeof this.props.fallback === "function") {
return (this.props.fallback as (retry: () => void) => ReactNode)(this.handleRetry);
}
return this.props.fallback;
}

return (
<div className="h-48 w-full rounded-md border bg-muted flex flex-col items-center justify-center gap-3 p-4">
<p className="text-sm text-muted-foreground text-center">
Something went wrong
</p>
<Button variant="outline" size="sm" onClick={this.handleRetry}>
Retry
</Button>
</div>
);
}

return this.props.children;
}
}
45 changes: 45 additions & 0 deletions components/invoices/FundingProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import { useEffect, useState } from "react";

interface FundingProgressBarProps {
raised: number;
target: number;
}

export function FundingProgressBar({ raised, target }: FundingProgressBarProps) {
const [animatedWidth, setAnimatedWidth] = useState(0);

const percentage = target > 0 ? Math.min((raised / target) * 100, 100) : 0;
const isFullyFunded = percentage >= 100;

useEffect(() => {
// Animate from 0 to the current percentage on mount
const timer = setTimeout(() => {
setAnimatedWidth(percentage);
}, 100);
return () => clearTimeout(timer);
}, [percentage]);

return (
<div className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-2">
<span>Raised</span>
<span>{percentage.toFixed(1)}%</span>
</div>
<div className="h-3 w-full rounded-full bg-secondary overflow-hidden">
<div
className={`h-full transition-all duration-1000 ease-out ${isFullyFunded ? "bg-green-500" : "bg-primary"
}`}
style={{ width: `${animatedWidth}%` }}
/>
</div>
</div>
<p className="text-sm text-muted-foreground">
{raised.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} XLM raised of{" "}
{target.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} XLM
</p>
</div>
);
}
40 changes: 8 additions & 32 deletions components/invoices/InvoiceDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import { fetchInvoiceDetail, type InvoiceDetail } from "@/lib/api";
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 { InvoiceStatusBadge } from "@/components/invoices/InvoiceStatusBadge";
import { FundingProgressBar } from "@/components/invoices/FundingProgressBar";
import { DocumentPreview } from "@/components/invoices/DocumentPreview";
import { CountdownTimer, isExpired } from "@/components/marketplace";

function InvoiceDetailSkeleton() {
Expand Down Expand Up @@ -106,9 +108,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<CardHeader>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{invoice.title}</h1>
<Badge variant={invoice.status === "open" ? "default" : "secondary"}>
{invoice.status}
</Badge>
<InvoiceStatusBadge status={invoice.status} />
</div>
<p className="text-sm text-muted-foreground">Seller: {invoice.seller}</p>
<CountdownTimer deadline={invoice.due_date} published={published} />
Expand All @@ -119,20 +119,9 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<CardHeader>
<h2 className="text-lg font-semibold">Funding Progress</h2>
</CardHeader>
<CardContent className="space-y-4">
<div>
<div className="flex justify-between text-sm mb-2">
<span>Raised</span>
<span>{progress.toFixed(1)}%</span>
</div>
<div className="h-3 w-full rounded-full bg-secondary overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{ width: `${progress}%` }}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
<CardContent>
<FundingProgressBar raised={invoice.raised} target={invoice.amount} />
<div className="mt-4 grid grid-cols-3 gap-4">
<div>
<p className="text-sm text-muted-foreground">Raised</p>
<p className="text-lg font-semibold">{invoice.raised.toLocaleString()} XLM</p>
Expand Down Expand Up @@ -181,20 +170,7 @@ export function InvoiceDetail({ invoiceId }: InvoiceDetailProps) {
<h2 className="text-lg font-semibold">Invoice Document</h2>
</CardHeader>
<CardContent>
{invoice.document_url ? (
<div className="h-48 w-full rounded-md border bg-muted flex items-center justify-center">
<a
href={invoice.document_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary underline"
>
View Document
</a>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-8">No document attached</p>
)}
<DocumentPreview documentUrl={invoice.document_url} />
</CardContent>
</Card>
</div>
Expand Down
28 changes: 28 additions & 0 deletions components/invoices/InvoiceStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use client";

import { Badge } from "@/components/ui/badge";

export type InvoiceStatus = "draft" | "published" | "funded" | "settled" | "expired";

interface InvoiceStatusBadgeProps {
status?: InvoiceStatus | string | null;
}

const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" | "ghost" | "link" }> = {
draft: { label: "Draft", variant: "secondary" },
published: { label: "Open", variant: "outline" },
open: { label: "Open", variant: "outline" },
funded: { label: "Funded", variant: "default" },
settled: { label: "Settled", variant: "ghost" },
expired: { label: "Expired", variant: "destructive" },
};

export function InvoiceStatusBadge({ status }: InvoiceStatusBadgeProps) {
if (status === null || status === undefined) {
return null;
}

const config = statusConfig[status] ?? { label: "Unknown", variant: "secondary" as const };

return <Badge variant={config.variant}>{config.label}</Badge>;
}
68 changes: 68 additions & 0 deletions components/invoices/__tests__/InvoiceFundingProgressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { FundingProgressBar } from "../FundingProgressBar";

beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

function advanceAnimation() {
vi.advanceTimersByTime(200);
}

describe("FundingProgressBar", () => {
it("renders the correct percentage for a partially funded invoice", () => {
render(<FundingProgressBar raised={4500} target={10000} />);
advanceAnimation();

expect(screen.getByText("45.0%")).toBeInTheDocument();
});

it("renders 100% when raised equals target", () => {
render(<FundingProgressBar raised={10000} target={10000} />);
advanceAnimation();

expect(screen.getByText("100.0%")).toBeInTheDocument();
});

it("caps at 100% when raised exceeds target", () => {
render(<FundingProgressBar raised={15000} target={10000} />);
advanceAnimation();

expect(screen.getByText("100.0%")).toBeInTheDocument();
});

it("renders green bar when 100% funded", () => {
render(<FundingProgressBar raised={10000} target={10000} />);
advanceAnimation();

const bar = screen.getByText("100.0%");
expect(bar).toBeInTheDocument();
});

it("displays XLM raised and target below the bar", () => {
render(<FundingProgressBar raised={4500} target={10000} />);
advanceAnimation();

expect(screen.getByText(/4,500\.00 XLM raised of 10,000\.00 XLM/)).toBeInTheDocument();
});

it("formats total to 2 decimal places", () => {
render(<FundingProgressBar raised={4500} target={10000} />);
advanceAnimation();

const formattedText = screen.getByText(/4,500\.00 XLM raised of 10,000\.00 XLM/);
expect(formattedText).toBeInTheDocument();
});

it("handles zero target gracefully", () => {
render(<FundingProgressBar raised={0} target={0} />);
advanceAnimation();

expect(screen.getByText("0.0%")).toBeInTheDocument();
});
});
Loading