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
6 changes: 6 additions & 0 deletions app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ import { CurrencySettings } from "@/components/settings/CurrencySettings";
import { RestartTourSettings } from "@/components/settings/RestartTourSettings";
import { NetworkSelector } from "@/components/network/NetworkSelector";
import { AutoInvestSettingsPanel } from "@/components/investor/AutoInvestSettingsPanel";
import { RolloverSettingsPanel } from "@/components/investor/RolloverSettingsPanel";

// #437 — general settings page; hosts the display-currency toggle.
// #443 — Stellar network selector.
// #476 — auto-invest rule configuration panel.
// #545 — invoice auto-rollover configuration panel.
export default function SettingsPage() {
return (
<main className="container mx-auto max-w-2xl space-y-6 px-4 py-8">
Expand All @@ -17,6 +19,10 @@ export default function SettingsPage() {
<h2 className="text-lg font-semibold">Auto-invest</h2>
<AutoInvestSettingsPanel />
</section>
<section id="rollover" className="space-y-3">
<h2 className="text-lg font-semibold">Auto-rollover</h2>
<RolloverSettingsPanel />
</section>
<section id="alerts" className="space-y-3">
<h2 className="text-lg font-semibold">Alerts</h2>
<Link
Expand Down
32 changes: 32 additions & 0 deletions app/profile/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
"use client";

import { useState } from "react";
import { format } from "date-fns";
import { UserProfile } from "@/components/dashboard/UserProfile";
import { RiskProfileQuestionnaire } from "@/components/onboarding/RiskProfileQuestionnaire";
import { AccreditationRenewalBanner } from "@/components/investor/AccreditationRenewalBanner";
import { AccreditationTierBadge } from "@/components/investor/AccreditationTierBadge";
import { Button } from "@/components/ui/button";
import { ThemeSelector } from "@/components/layout/ThemeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { useAccreditationStatus } from "@/hooks/useAccreditationStatus";
import { usePageTitle } from "@/hooks/usePageTitle";
import { useSuitabilityTier } from "@/hooks/useSuitabilityTier";
import { TIER_LABELS } from "@/lib/suitability";
Expand All @@ -14,12 +18,40 @@ export default function ProfilePage() {
usePageTitle("Profile");
const { tier, setTier } = useSuitabilityTier();
const [retaking, setRetaking] = useState(false);
const { data: accreditation, isError: accreditationError } = useAccreditationStatus();
const expiry = accreditation?.expires_at ? new Date(accreditation.expires_at) : null;
const accreditationExpiry = expiry && !Number.isNaN(expiry.getTime()) ? expiry : null;

return (
<main className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-2xl font-bold">Profile</h1>
<div className="mb-6">
<AccreditationRenewalBanner />
</div>
<UserProfile />

<section id="accreditation" className="mt-8 space-y-3" aria-labelledby="accreditation-heading">
<h2 id="accreditation-heading" className="text-lg font-semibold">
Accreditation
</h2>
{accreditation ? (
<div className="flex flex-wrap items-center gap-3">
<AccreditationTierBadge tier={accreditation.tier} />
{accreditationExpiry && (
<p className="text-sm text-muted-foreground" data-testid="profile-accreditation-expiry">
Expires {format(accreditationExpiry, "MMM d, yyyy")}
</p>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
{accreditationError
? "Couldn't load your accreditation status."
: "Connect your wallet to see your accreditation status."}
</p>
)}
</section>

<section className="mt-8 space-y-3" aria-labelledby="risk-profile-heading">
<h2 id="risk-profile-heading" className="text-lg font-semibold">
Risk profile
Expand Down
2 changes: 2 additions & 0 deletions components/dashboard/InvestorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { usePortfolioSummary, useRecentActivity, useUpcomingMaturities } from "@
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Money, ExchangeRateDisclaimer } from "@/components/currency";
import { AccreditationRenewalBanner } from "@/components/investor/AccreditationRenewalBanner";

export function InvestorDashboard() {
const { data: portfolio, isLoading: portfolioLoading } = usePortfolioSummary();
Expand All @@ -28,6 +29,7 @@ export function InvestorDashboard() {

return (
<div className="space-y-6">
<AccreditationRenewalBanner />
<div>
<h1 className="text-3xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s an overview of your portfolio.</p>
Expand Down
86 changes: 86 additions & 0 deletions components/investor/AccreditationRenewalBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useEffect, useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { useAccreditationStatus } from "@/hooks/useAccreditationStatus";
import {
ACCREDITATION_RENEWAL_ROUTE,
getRenewalCountdown,
getRenewalSeverity,
type RenewalSeverity,
} from "@/lib/accreditationRenewal";
import { cn } from "@/lib/utils";

const SEVERITY_STYLES: Record<RenewalSeverity, string> = {
notice: "border-yellow-300 bg-yellow-50 text-yellow-900 dark:border-yellow-700 dark:bg-yellow-950/30 dark:text-yellow-100",
warning: "border-amber-400 bg-amber-50 text-amber-900 dark:border-amber-600 dark:bg-amber-950/30 dark:text-amber-100",
critical: "border-red-400 bg-red-50 text-red-900 dark:border-red-700 dark:bg-red-950/30 dark:text-red-100",
};

/** Countdown precision is hours, so the clock only needs to tick once a minute. */
const TICK_MS = 60_000;

/**
* Renewal prompt for the connected investor's accreditation (#543).
*
* Shown on the dashboard and profile once the expiry is under 60 days away:
* yellow below 60 days, amber below 30, red below 7. Renewing moves the expiry
* out of range, so the banner disappears on the next status refresh.
*/
export function AccreditationRenewalBanner() {
const { data } = useAccreditationStatus();
const expiresAt = data?.expires_at ?? null;

const [now, setNow] = useState(() => Date.now());

useEffect(() => {
if (!expiresAt) return;
const id = setInterval(() => setNow(Date.now()), TICK_MS);
return () => clearInterval(id);
}, [expiresAt]);

const severity = getRenewalSeverity(expiresAt, now);
if (!expiresAt || !severity) return null;

const { days, hours, expired } = getRenewalCountdown(expiresAt, now);
const isCritical = severity === "critical";

return (
<div
role={isCritical ? "alert" : "status"}
data-testid="accreditation-renewal-banner"
data-severity={severity}
className={cn(
"flex flex-col gap-3 rounded-md border px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between",
SEVERITY_STYLES[severity],
)}
>
<div className="space-y-0.5">
<p className="font-medium">
{expired ? "Your accreditation has expired" : "Your accreditation is expiring soon"}
</p>
<p className="text-xs opacity-80">
{expired ? (
"Renew to keep investing in high-value invoices."
) : (
<>
Time left:{" "}
<span className="font-mono" data-testid="accreditation-renewal-countdown">
{days}d {hours}h
</span>
</>
)}
</p>
</div>
<Button
asChild
size="sm"
variant={isCritical ? "destructive" : "default"}
className="shrink-0"
>
<Link href={ACCREDITATION_RENEWAL_ROUTE}>Renew now</Link>
</Button>
</div>
);
}
56 changes: 56 additions & 0 deletions components/investor/DisableRolloverDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

interface DisableRolloverDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending?: boolean;
}

/**
* Confirmation before `disable_rollover` is submitted (#545). Matured positions
* will no longer be reinvested until the investor turns rollover back on.
*/
export function DisableRolloverDialog({
open,
onOpenChange,
onConfirm,
isPending = false,
}: DisableRolloverDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent data-testid="disable-rollover-dialog">
<DialogHeader>
<DialogTitle>Disable auto-rollover?</DialogTitle>
<DialogDescription>
Matured positions will stop being reinvested automatically. Your saved
criteria are kept, so you can re-enable rollover at any time.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
Keep it on
</Button>
<Button
variant="destructive"
onClick={onConfirm}
disabled={isPending}
data-testid="confirm-disable-rollover"
>
{isPending ? "Disabling…" : "Disable auto-rollover"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
76 changes: 76 additions & 0 deletions components/investor/RolloverActivityList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"use client";

import Link from "next/link";
import { format } from "date-fns";
import { CheckCircle2, History } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import type { RolloverExecutedEvent } from "@/lib/api";
import { sectorLabel } from "@/lib/autoInvest";

interface RolloverActivityListProps {
events: RolloverExecutedEvent[];
}

/** Recent `RolloverExecuted` events: matured positions reinvested automatically (#545). */
export function RolloverActivityList({ events }: RolloverActivityListProps) {
return (
<Card data-testid="rollover-activity">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<History className="h-4 w-4" aria-hidden="true" />
Recent rollover activity
</CardTitle>
<CardDescription>Positions reinvested automatically at maturity</CardDescription>
</CardHeader>
<CardContent>
{events.length === 0 ? (
<p
className="py-6 text-center text-sm text-muted-foreground"
data-testid="rollover-activity-empty"
>
No rollovers yet. Your criteria apply when a position matures.
</p>
) : (
<ul className="divide-y">
{events.map((event) => (
<li
key={event.id}
className="flex items-center justify-between gap-4 py-3"
data-testid={`rollover-event-${event.id}`}
>
<div className="flex min-w-0 items-center gap-3">
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" aria-hidden="true" />
<div className="min-w-0">
<Link
href={`/marketplace/${event.invoice_id}`}
className="truncate text-sm font-medium hover:underline"
>
{event.invoice_title}
</Link>
<p className="text-xs text-muted-foreground">
{event.sector ? `${sectorLabel(event.sector)} · ` : ""}
{format(new Date(event.executed_at), "MMM d, yyyy HH:mm")}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2 text-right">
<Badge variant="secondary">{event.yield_percentage}%</Badge>
<span className="font-mono text-sm font-medium">
{event.amount.toLocaleString()} XLM
</span>
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
Loading