diff --git a/src/components/FundingProgress.tsx b/src/components/FundingProgress.tsx
index 5c4f946..a5ac098 100644
--- a/src/components/FundingProgress.tsx
+++ b/src/components/FundingProgress.tsx
@@ -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 {
@@ -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));
@@ -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 (
{!compact && (
{label}
)}
-
+ {/*
+ * The wrapper is `relative` with `overflow-visible` so tick labels
+ * can render above/below without being clipped.
+ */}
+
+ 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"
+ >
+
+
+
+ {/* Milestone tick marks */}
+ {resolvedMilestones.map((m) => {
+ const reached = clamped >= m.pct;
+ return (
+
+ {/* Tick line */}
+
+ {/* Optional label below */}
+ {m.label && (
+
+ {m.label}
+
+ )}
+
+ );
+ })}
);
diff --git a/src/components/PayoutScheduler.tsx b/src/components/PayoutScheduler.tsx
index c592add..807eb60 100644
--- a/src/components/PayoutScheduler.tsx
+++ b/src/components/PayoutScheduler.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
interface Props {
invoiceId: string;
@@ -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); }
@@ -77,9 +117,23 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }:
{cliffPassed ? (
Vesting cliff passed — you can claim now.
) : (
-
- Cliff on {new Date(vestingCliff * 1000).toLocaleDateString()}. Schedule a reminder.
-
+
+
+ Cliff on{" "}
+ {new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
+ new Date(vestingCliff * 1000)
+ )}{" "}
+ {tzAbbr}. Schedule a reminder.
+
+
+ {new Intl.DateTimeFormat("en-US", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: "UTC",
+ }).format(new Date(vestingCliff * 1000))}{" "}
+ UTC
+
+
)}
{!saved ? (
@@ -101,9 +155,17 @@ export default function PayoutScheduler({ invoiceId, vestingCliff, publicKey }:
) : (
-
- Scheduled: {new Date(date).toLocaleString()}
-
+
+
+ Scheduled:{" "}
+ {formatWithTz(date).local}
+
+ {formatWithTz(date).utc && (
+
+ {formatWithTz(date).utc}
+
+ )}
+