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
9 changes: 9 additions & 0 deletions app/kyc/renew/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

import Link from "next/link";
import { Button } from "@/components/ui/button";
import { KycDocumentStatusDashboard } from "@/components/kyc/KycDocumentStatusDashboard";

export default function KycRenewPage() {
return <main className="container mx-auto max-w-2xl space-y-6 px-4 py-8"><div><h1 className="text-2xl font-bold">Renew investor accreditation</h1><p className="mt-1 text-sm text-muted-foreground">Replace rejected or expired evidence. Each document returns to Under Review after submission.</p></div><KycDocumentStatusDashboard /><Button asChild variant="outline"><Link href="/kyc/status">Back to verification status</Link></Button></main>;
}
2 changes: 1 addition & 1 deletion app/kyc/status/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export default function KycStatusPage() {
const { data, isLoading } = useQuery<{ status: KS; submittedAt: string; reviewedAt?: string; rejectionReason?: string }>({ queryKey: ["kyc-status"], queryFn: async () => { const r = await fetch("/api/kyc/status"); if (!r.ok) throw new Error("Failed"); return r.json(); }, refetchInterval: 60_000 });
if (isLoading) return <div className="container mx-auto max-w-lg px-4 py-12"><Card><CardContent className="p-8 text-center text-muted-foreground">Loading…</CardContent></Card></div>;
const s = data?.status ?? "pending"; const cfg = C[s];
return (<div className="container mx-auto max-w-lg px-4 py-12"><Card><CardHeader><CardTitle>KYC Verification Status</CardTitle></CardHeader><CardContent className="space-y-4"><div className={`inline-flex rounded-full px-3 py-1 text-sm font-semibold ${cfg.b} ${cfg.c}`}>{cfg.l}</div><p className="text-sm text-muted-foreground">Submitted: {data?.submittedAt ? new Date(data.submittedAt).toLocaleDateString() : "—"}</p>{s === "rejected" && data?.rejectionReason && <div className="rounded-md bg-red-50 p-3 text-sm text-red-700"><p className="font-semibold">Rejection reason:</p><p>{data.rejectionReason}</p></div>}{s === "rejected" && <Link href="/kyc/reapply"><Button className="w-full">Resubmit KYC</Button></Link>}{s === "pending" && <p className="text-xs text-muted-foreground text-center">Refreshes every 60s.</p>}</CardContent></Card><div className="mt-8"><KycDocumentStatusDashboard /></div></div>);
return (<div className="container mx-auto max-w-lg px-4 py-12"><Card><CardHeader><CardTitle>KYC Verification Status</CardTitle></CardHeader><CardContent className="space-y-4"><div className={`inline-flex rounded-full px-3 py-1 text-sm font-semibold ${cfg.b} ${cfg.c}`}>{cfg.l}</div><p className="text-sm text-muted-foreground">Submitted: {data?.submittedAt ? new Date(data.submittedAt).toLocaleDateString() : "—"}</p>{s === "rejected" && data?.rejectionReason && <div className="rounded-md bg-red-50 p-3 text-sm text-red-700"><p className="font-semibold">Rejection reason:</p><p>{data.rejectionReason}</p></div>}{s === "rejected" && <Link href="/kyc/renew"><Button className="w-full">Renew accreditation</Button></Link>}{s === "pending" && <p className="text-xs text-muted-foreground text-center">Refreshes every 60s.</p>}</CardContent></Card><div className="mt-8"><KycDocumentStatusDashboard /></div></div>);
}
3 changes: 2 additions & 1 deletion components/dashboard/InvestorPortfolioPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { FractionTransferModal } from "@/components/dashboard/FractionTransferMo
import { SettlementCountdown } from "@/components/marketplace/SettlementCountdown";
import { Download, ExternalLink } from "lucide-react";
import { PortfolioRiskIndicator } from "@/components/invoices/InvoiceRiskAlert";
import { ListPositionButton } from "@/components/dashboard/ListPositionButton";

// ─── Types ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -153,7 +154,7 @@ function HoldingsTab() {
</td>
<td className="p-3 text-right">
<div className="flex justify-end">
<FractionTransferModal position={pos} />
<div className="flex gap-2"><FractionTransferModal position={pos} /><ListPositionButton position={pos} /></div>
</div>
</td>
</tr>
Expand Down
24 changes: 24 additions & 0 deletions components/dashboard/ListPositionButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"use client";

import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { createListing } from "@/lib/listingFee";
import { fetchListingFeeConfig, computeListingFee } from "@/lib/listingFee";
import type { InvestmentPosition } from "@/lib/portfolio";

export function ListPositionButton({ position }: { position: InvestmentPosition }) {
const [open, setOpen] = useState(false);
const [quantity, setQuantity] = useState(String(Math.max(0, (position.quantity ?? 0) - (position.listed_quantity ?? 0))));
const [price, setPrice] = useState("");
const fee = useQuery({ queryKey: ["listing-fee"], queryFn: () => fetchListingFeeConfig(), enabled: open });
const qty = Number(quantity); const ask = Number(price);
const preview = computeListingFee(qty, ask, fee.data?.fee_bps ?? 0);
const mutation = useMutation({ mutationFn: () => createListing({ invoiceId: position.invoice_id, quantity: qty, askPrice: ask }), onSuccess: () => setOpen(false) });
const locked = Boolean(position.lockup_expires_at && new Date(position.lockup_expires_at).getTime() > Date.now());
const available = Math.max(0, (position.quantity ?? 0) - (position.listed_quantity ?? 0));
return <><Button size="sm" variant="outline" disabled={locked || available === 0} onClick={() => setOpen(true)} title={locked ? `Locked until ${new Date(position.lockup_expires_at!).toLocaleDateString()}` : undefined}>List for resale</Button><Dialog open={open} onOpenChange={setOpen}><DialogContent><DialogHeader><DialogTitle>List {position.invoice_title} for resale</DialogTitle></DialogHeader>{locked ? <p className="text-sm text-destructive">This position is locked until {new Date(position.lockup_expires_at!).toLocaleString()}.</p> : <div className="space-y-3"><p className="text-sm text-muted-foreground">Available fractions: {available}</p><div><Label htmlFor="listing-quantity">Fractions</Label><Input id="listing-quantity" type="number" min={1} max={available} value={quantity} onChange={(e) => setQuantity(e.target.value)} /></div><div><Label htmlFor="listing-price">Ask price per fraction (XLM)</Label><Input id="listing-price" type="number" min={0} value={price} onChange={(e) => setPrice(e.target.value)} /></div><dl className="text-sm"><div className="flex justify-between"><dt>Gross</dt><dd>{preview.gross.toFixed(2)} XLM</dd></div><div className="flex justify-between"><dt>Platform fee</dt><dd>{preview.fee.toFixed(2)} XLM</dd></div><div className="flex justify-between font-semibold"><dt>Net proceeds</dt><dd>{preview.netProceeds.toFixed(2)} XLM</dd></div></dl></div>}<DialogFooter><Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button><Button disabled={locked || qty < 1 || qty > available || ask <= 0 || mutation.isPending} onClick={() => mutation.mutate()}>{mutation.isPending ? "Submitting…" : "Confirm listing"}</Button></DialogFooter></DialogContent></Dialog></>;
}
13 changes: 13 additions & 0 deletions components/invoices/DynamicYieldCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { fetchDynamicYield } from "@/lib/api";

export function DynamicYieldCard({ wallet, invoiceRate }: { wallet: string; invoiceRate?: number | null }) {
const query = useQuery({ queryKey: ["dynamic-yield", wallet], queryFn: () => fetchDynamicYield(wallet), enabled: Boolean(wallet) });
const data = query.data;
if (query.isLoading || query.isError || !data) return null;
const aboveBase = data.current_rate >= data.base_rate;
return <Card data-testid="dynamic-yield-card"><CardHeader className="pb-2"><CardTitle className="text-base">Dynamic yield</CardTitle></CardHeader><CardContent className="space-y-2 text-sm"><div className={`text-lg font-semibold ${aboveBase ? "text-green-700" : "text-amber-700"}`}>{data.current_rate.toFixed(2)}% <span className="text-xs font-normal">current issuer rate</span></div><p className="text-muted-foreground">Base rate {data.base_rate.toFixed(2)}% · {data.settlement_count} on-time settlements</p>{invoiceRate != null && <p className="text-xs text-muted-foreground">This invoice was priced at {invoiceRate.toFixed(2)}%.</p>}{data.adjustment_history.length > 0 && <ul className="flex gap-2 overflow-x-auto pt-1" aria-label="Yield adjustment history">{data.adjustment_history.slice(-10).map((item) => <li key={`${item.effective_at}-${item.rate}`} className="rounded border px-2 py-1 text-xs">{item.rate.toFixed(2)}%</li>)}</ul>}</CardContent></Card>;
}
3 changes: 3 additions & 0 deletions components/invoices/InvoiceDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import { truncateAddress } from "@/lib/stellar";
import { DEFAULT_MIN_INVESTMENT } from "@/lib/invoiceDefaults";
import { Money } from "@/components/currency";
import { CurrencyToggle } from "@/components/layout/CurrencyToggle";
import { DynamicYieldCard } from "@/components/invoices/DynamicYieldCard";

function InvoiceDetailSkeleton() {
return (
Expand Down Expand Up @@ -424,6 +425,8 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {

<OrderBookDepthChart invoiceId={invoice.id} />

<DynamicYieldCard wallet={invoice.seller} invoiceRate={invoice.yield_percentage} />

{/* Invest CTA — KYC gated. */}
{isOpenForInvestment ? (
<InvestCallToAction
Expand Down
14 changes: 14 additions & 0 deletions lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,20 @@ export interface InvoiceDetail extends Invoice {
};
}

export interface DynamicYieldResponse {
current_rate: number;
base_rate: number;
settlement_count: number;
adjustment_history: { rate: number; effective_at: string; reason?: string }[];
}

export async function fetchDynamicYield(wallet: string): Promise<DynamicYieldResponse | null> {
const res = await fetch(`${API_BASE}/issuers/${encodeURIComponent(wallet)}/dynamic-yield`);
if (res.status === 404) return null;
if (!res.ok) throw new Error("Failed to fetch issuer yield history");
return res.json();
}

export interface InvoicesResponse {
invoices: Invoice[];
has_more: boolean;
Expand Down