Skip to content
Open
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
98 changes: 85 additions & 13 deletions src/components/FundingProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,26 @@
import { useEffect, useState } from "react";
import { formatAmount } from "@stellar-split/sdk";

/**
* A milestone can be expressed either as a percentage (0–100)
* or as an absolute amount (bigint). When expressed as an amount,
* it is converted to a percentage relative to `total`.
*/
export type MilestoneInput =
| { type: "percent"; value: number; label?: string }
| { type: "amount"; value: bigint; label?: string };

interface Props {
funded: bigint;
total: bigint;
token?: string;
/** compact hides the text label */
compact?: boolean;
/**
* Optional milestone markers. Each entry renders a tick on the bar.
* Accepts percentages or absolute amounts (converted to % internally).
*/
milestones?: MilestoneInput[];
}

function getBarColor(pct: number): string {
Expand All @@ -19,10 +33,17 @@ function getBarColor(pct: number): string {
}

/**
* FundingProgress — animated horizontal bar with colour transitions.
* FundingProgress — animated horizontal bar with colour transitions
* and optional milestone tick marks.
* Animates from 0 → actual value on first render (600 ms).
*/
export default function FundingProgress({ funded, total, token = "USDC", compact = false }: Props) {
export default function FundingProgress({
funded,
total,
token = "USDC",
compact = false,
milestones,
}: Props) {
const rawPct = total === 0n ? 0 : Number((funded * 100n) / total);
const clamped = Math.min(100, Math.max(0, rawPct));

Expand All @@ -36,23 +57,74 @@ export default function FundingProgress({ funded, total, token = "USDC", compact

const label = `${formatAmount(funded)} ${token} of ${formatAmount(total)} ${token} funded (${clamped}%)`;

/** Resolve milestones to normalised percentage values (0–100). */
const resolvedMilestones: Array<{ pct: number; label?: string }> =
(milestones ?? [])
.map((m) => {
if (m.type === "percent") {
return { pct: Math.min(100, Math.max(0, m.value)), label: m.label };
}
// amount — convert to percentage
const pct =
total === 0n ? 0 : Number((m.value * 100n) / total);
return { pct: Math.min(100, Math.max(0, pct)), label: m.label };
})
// Filter out 0% and 100% edge markers
.filter((m) => m.pct > 0 && m.pct < 100);

return (
<div>
{!compact && (
<p className="text-xs text-gray-400 mb-1">{label}</p>
)}
<div
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className="w-full bg-gray-700 rounded-full h-2 overflow-hidden"
>
{/*
* The wrapper is `relative` with `overflow-visible` so tick labels
* can render above/below without being clipped.
*/}
<div className="relative">
<div
className={`h-full rounded-full transition-all duration-[600ms] ease-out ${getBarColor(clamped)}`}
style={{ width: `${width}%` }}
/>
role="progressbar"
aria-valuenow={clamped}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
className="w-full bg-gray-700 rounded-full h-2 overflow-hidden"
>
<div
className={`h-full rounded-full transition-all duration-[600ms] ease-out ${getBarColor(clamped)}`}
style={{ width: `${width}%` }}
/>
</div>

{/* Milestone tick marks */}
{resolvedMilestones.map((m) => {
const reached = clamped >= m.pct;
return (
<div
key={m.pct}
className="absolute top-0 flex flex-col items-center"
style={{ left: `${m.pct}%`, transform: "translateX(-50%)" }}
>
{/* Tick line */}
<div
aria-hidden="true"
className={`w-0.5 h-3 -mt-0.5 rounded-full transition-colors duration-300 ${
reached ? "bg-green-400" : "bg-gray-500"
}`}
/>
{/* Optional label below */}
{m.label && (
<span
className={`mt-0.5 text-[10px] leading-none whitespace-nowrap transition-colors duration-300 ${
reached ? "text-green-400" : "text-gray-500"
}`}
>
{m.label}
</span>
)}
</div>
);
})}
</div>
</div>
);
Expand Down
76 changes: 69 additions & 7 deletions src/components/PayoutScheduler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";

interface Props {
invoiceId: string;
Expand Down Expand Up @@ -38,6 +38,46 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }:

const cliffPassed = Math.floor(Date.now() / 1000) >= vestingCliff;

// Resolve the browser timezone abbreviation once (e.g. "WAT", "EST")
const tzAbbr = useMemo(() => {
try {
const ianaZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Extract the abbreviation from a formatted date string
const sample = new Intl.DateTimeFormat(undefined, {
timeZoneName: "short",
hour: "numeric",
timeZone: ianaZone,
}).formatToParts(new Date());
return sample.find((p) => p.type === "timeZoneName")?.value ?? ianaZone;
} catch {
return "local";
}
}, []);

/**
* Formats a datetime-local string (YYYY-MM-DDTHH:MM) to show:
* - Local time with timezone abbreviation: "3:00 PM WAT"
* - UTC equivalent below: "2:00 PM UTC"
*/
const formatWithTz = (datetimeLocal: string) => {
if (!datetimeLocal) return { local: "", utc: "" };
const d = new Date(datetimeLocal);
if (Number.isNaN(d.getTime())) return { local: datetimeLocal, utc: "" };

const localStr = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(d);

const utcStr = new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(d);

return { local: `${localStr} ${tzAbbr}`, utc: `${utcStr} UTC` };
};

useEffect(() => {
const existing = loadSchedules().find((s) => s.invoiceId === invoiceId);
if (existing) { setDate(existing.date); setSaved(true); }
Expand Down Expand Up @@ -77,9 +117,23 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }:
{cliffPassed ? (
<p className="text-green-400 text-sm mb-3">Vesting cliff passed — you can claim now.</p>
) : (
<p className="text-sm text-gray-400 mb-3">
Cliff on {new Date(vestingCliff * 1000).toLocaleDateString()}. Schedule a reminder.
</p>
<div className="mb-3">
<p className="text-sm text-gray-400">
Cliff on{" "}
{new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(vestingCliff * 1000)
)}{" "}
{tzAbbr}. Schedule a reminder.
</p>
<p className="text-xs text-gray-500">
{new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "UTC",
}).format(new Date(vestingCliff * 1000))}{" "}
UTC
</p>
</div>
)}

{!saved ? (
Expand All @@ -101,9 +155,17 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }:
</form>
) : (
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="text-sm text-gray-300">
Scheduled: <span className="text-indigo-300">{new Date(date).toLocaleString()}</span>
</p>
<div>
<p className="text-sm text-gray-300">
Scheduled:{" "}
<span className="text-indigo-300">{formatWithTz(date).local}</span>
</p>
{formatWithTz(date).utc && (
<p className="text-xs text-gray-500 mt-0.5">
{formatWithTz(date).utc}
</p>
)}
</div>
<button
onClick={handleCancel}
className="min-h-11 px-3 py-2 rounded-lg bg-gray-800 hover:bg-gray-700 text-sm text-gray-400 transition-colors"
Expand Down
50 changes: 45 additions & 5 deletions src/components/SubscriptionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
"use client";

import { useMemo } from "react";
import Link from "next/link";
import type { Subscription } from "@/types/subscription";
import {
formatFrequency,
formatSubscriptionForDisplay,
} from "@/lib/subscriptions";

/**
* Returns a human-readable relative time string for a Unix timestamp.
* - Overdue → "Overdue by X days" (red)
* - < 24 h → "in X hours"
* - Otherwise → "in X days" / "Tomorrow" / "Today"
*/
function useRelativeBillingDate(nextRunDate: number): { label: string; overdue: boolean } {
return useMemo(() => {
const now = Date.now();
const target = nextRunDate * 1000;
const diffMs = target - now;
const diffHours = diffMs / (1000 * 60 * 60);
const diffDays = diffMs / (1000 * 60 * 60 * 24);

if (diffMs < 0) {
const overdueDays = Math.abs(Math.ceil(diffDays));
return { label: `Overdue by ${overdueDays} day${overdueDays === 1 ? "" : "s"}`, overdue: true };
}

const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });

if (diffHours < 24) {
const hours = Math.ceil(diffHours);
return { label: formatter.format(hours, "hour"), overdue: false };
}

const days = Math.ceil(diffDays);
return { label: formatter.format(days, "day"), overdue: false };
}, [nextRunDate]);
}

interface Props {
subscription: Subscription;
}
Expand All @@ -19,6 +51,7 @@ const STATUS_STYLES: Record<string, string> = {

export default function SubscriptionCard({ subscription }: Props) {
const display = formatSubscriptionForDisplay(subscription);
const { label: relativeLabel, overdue } = useRelativeBillingDate(subscription.nextRunDate);

return (
<Link
Expand All @@ -44,11 +77,18 @@ export default function SubscriptionCard({ subscription }: Props) {
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-gray-500 text-xs">Next Run</p>
<p className="text-gray-300">
{subscription.status === "active"
? display.nextRunDateFormatted
: "—"}
</p>
{subscription.status === "active" ? (
<p className="text-gray-300">
{display.nextRunDateFormatted}
<span
className={`ml-1.5 text-xs ${overdue ? "text-red-400 font-medium" : "text-gray-500"}`}
>
· {relativeLabel}
</span>
</p>
) : (
<p className="text-gray-300">—</p>
)}
</div>
<div>
<p className="text-gray-500 text-xs">Invoices</p>
Expand Down
57 changes: 57 additions & 0 deletions src/components/SubscriptionHistoryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,39 @@ const STATUS_STYLES: Record<string, string> = {
Refunded: "text-gray-400",
};

/** Escapes a value for safe inclusion in a CSV cell. */
function escapeCSV(value: string): string {
if (/[",\n\r]/.test(value)) {
return `"${value.replace(/"/g, '""')}"`;
}
return value;
}

/** Builds and triggers a CSV download for the given history rows. */
function downloadHistoryCSV(history: SubscriptionInvoice[]): void {
const headers = ["Date", "Amount", "Currency", "Status", "Transaction Hash"];
const rows = history.map((inv) => [
new Date(inv.generatedAt * 1000).toISOString(),
formatAmount(inv.amount),
"USDC",
inv.status,
inv.invoiceId,
]);

const csvContent = [headers, ...rows]
.map((row) => row.map(escapeCSV).join(","))
.join("\n");

const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const today = new Date().toISOString().slice(0, 10);
const a = document.createElement("a");
a.href = url;
a.download = `subscription-history-${today}.csv`;
a.click();
URL.revokeObjectURL(url);
}

export default function SubscriptionHistoryTable({ history }: Props) {
if (history.length === 0) {
return (
Expand All @@ -24,6 +57,30 @@ export default function SubscriptionHistoryTable({ history }: Props) {

return (
<div>
{/* Export toolbar */}
<div className="flex justify-end mb-3">
<button
type="button"
onClick={() => downloadHistoryCSV(history)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-gray-800 hover:bg-gray-700 border border-gray-700 text-xs text-gray-300 font-medium transition-colors"
aria-label="Export subscription history as CSV"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
Export CSV
</button>
</div>
{/* Desktop table */}
<div className="hidden sm:block overflow-x-auto">
<table className="w-full text-sm">
Expand Down