From d6e9b21291d2163d500f25ac7dd8e5071d7cad43 Mon Sep 17 00:00:00 2001
From: trustosaretin
Date: Wed, 21 Oct 2026 04:33:01 +0100
Subject: [PATCH 1/5] chore(query-keys): register early incentive, secondary
prices, payment schedule, issuer verification keys
---
src/lib/queryKeys.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts
index 35e74288..48a7fd7c 100644
--- a/src/lib/queryKeys.ts
+++ b/src/lib/queryKeys.ts
@@ -83,6 +83,12 @@ export const queryKeys = {
: (['creators', creatorId, 'lockup'] as const),
whitelist: (creatorId: string) =>
['creators', creatorId, 'whitelist'] as const,
+ earlyIncentive: (creatorId: string) =>
+ ['creators', creatorId, 'early-incentive'] as const,
+ secondaryPrices: (creatorId: string, range: string) =>
+ ['creators', creatorId, 'secondary-prices', range] as const,
+ paymentSchedule: (creatorId: string) =>
+ ['creators', creatorId, 'payment-schedule'] as const,
publicProfile: (address: string) =>
['creators', 'public-profile', address] as const,
keyDeployment: (keyId: string) =>
From 5a09a2f4216e968b931b6fa980dd59c561da08f5 Mon Sep 17 00:00:00 2001
From: trustosaretin
Date: Wed, 21 Oct 2026 04:33:07 +0100
Subject: [PATCH 2/5] feat(keys): add early investor bonus chip on invest panel
(#1232)
---
.../common/EarlyInvestorBonusChip.tsx | 78 +++++++++++++++++++
src/hooks/useEarlyIncentive.ts | 20 +++++
src/services/earlyIncentive.service.ts | 76 ++++++++++++++++++
3 files changed, 174 insertions(+)
create mode 100644 src/components/common/EarlyInvestorBonusChip.tsx
create mode 100644 src/hooks/useEarlyIncentive.ts
create mode 100644 src/services/earlyIncentive.service.ts
diff --git a/src/components/common/EarlyInvestorBonusChip.tsx b/src/components/common/EarlyInvestorBonusChip.tsx
new file mode 100644
index 00000000..d9e2d0a1
--- /dev/null
+++ b/src/components/common/EarlyInvestorBonusChip.tsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import { Sparkles } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { EarlyIncentive } from '@/services/earlyIncentive.service';
+
+export interface EarlyInvestorBonusChipProps {
+ incentive?: EarlyIncentive | null;
+ isLoading?: boolean;
+ className?: string;
+}
+
+/**
+ * Early investor bonus chip (#1232).
+ *
+ * Surfaces the remaining early funding incentive slots on the invest panel
+ * to drive urgency. Renders the greyed "Early spots filled" state once every
+ * slot is taken, and nothing at all when the key has no incentive configured.
+ */
+export const EarlyInvestorBonusChip: React.FC = ({
+ incentive,
+ isLoading = false,
+ className,
+}) => {
+ if (isLoading) return null;
+ if (!incentive || !incentive.configured) return null;
+
+ const filled = incentive.slotsRemaining <= 0;
+
+ return (
+
+
+ {filled ? (
+ Early spots filled
+ ) : (
+ <>
+
+ +{incentive.bonusBps} bps bonus
+
+
+ ·
+
+
+ {incentive.slotsRemaining}{' '}
+ {incentive.slotsRemaining === 1 ? 'slot' : 'slots'} left
+
+
+ ·
+
+ Limited spots
+ >
+ )}
+
+ );
+};
+
+export default EarlyInvestorBonusChip;
diff --git a/src/hooks/useEarlyIncentive.ts b/src/hooks/useEarlyIncentive.ts
new file mode 100644
index 00000000..d613fd5c
--- /dev/null
+++ b/src/hooks/useEarlyIncentive.ts
@@ -0,0 +1,20 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import {
+ fetchEarlyIncentive,
+ type EarlyIncentive,
+} from '@/services/earlyIncentive.service';
+
+/** How often early incentive slot counts are refreshed (#1232). */
+export const EARLY_INCENTIVE_REFRESH_INTERVAL_MS = 30_000;
+
+export function useEarlyIncentive(keyId: string) {
+ return useQuery({
+ queryKey: queryKeys.creators.earlyIncentive(keyId),
+ queryFn: () => fetchEarlyIncentive(keyId),
+ enabled: !!keyId,
+ staleTime: EARLY_INCENTIVE_REFRESH_INTERVAL_MS,
+ refetchInterval: EARLY_INCENTIVE_REFRESH_INTERVAL_MS,
+ retry: false,
+ });
+}
diff --git a/src/services/earlyIncentive.service.ts b/src/services/earlyIncentive.service.ts
new file mode 100644
index 00000000..844fc30a
--- /dev/null
+++ b/src/services/earlyIncentive.service.ts
@@ -0,0 +1,76 @@
+import { BaseApiService, type APIResponse } from './api.service';
+
+/** Early funding incentive returned by GET /keys/:keyId/early-incentive. */
+export interface EarlyIncentive {
+ /** Bonus yield in basis points granted to early investors. */
+ bonusBps: number;
+ /** Remaining early funding slots; 0 means every slot is taken. */
+ slotsRemaining: number;
+ /** Total early funding slots, when the API reports it. */
+ slotsTotal?: number;
+ /** False when the key has no early incentive configured. */
+ configured: boolean;
+}
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return typeof value === 'object' && value !== null
+ ? (value as UnknownRecord)
+ : {};
+}
+
+function asNonNegativeInt(value: unknown): number {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : 0;
+}
+
+/**
+ * Normalize the early-incentive payload so the UI never depends on
+ * backend field aliases (slots_remaining vs slotsRemaining, etc.).
+ */
+export function normalizeEarlyIncentive(value: unknown): EarlyIncentive {
+ const raw = asRecord(value);
+ const bonusBps = asNonNegativeInt(raw.bonusBps ?? raw.bonus_bps ?? raw.bps);
+ const slotsRemaining = asNonNegativeInt(
+ raw.slotsRemaining ?? raw.slots_remaining ?? raw.remainingSlots
+ );
+ const slotsTotalRaw =
+ raw.slotsTotal ?? raw.slots_total ?? raw.totalSlots ?? undefined;
+ const slotsTotal =
+ slotsTotalRaw === undefined || slotsTotalRaw === null
+ ? undefined
+ : asNonNegativeInt(slotsTotalRaw);
+
+ const configuredFlag = raw.configured ?? raw.isConfigured ?? raw.active;
+ const configured =
+ typeof configuredFlag === 'boolean'
+ ? configuredFlag
+ : bonusBps > 0 || slotsRemaining > 0 || Boolean(slotsTotal);
+
+ return {
+ bonusBps,
+ slotsRemaining,
+ ...(slotsTotal !== undefined ? { slotsTotal } : {}),
+ configured,
+ };
+}
+
+class EarlyIncentiveService extends BaseApiService {
+ async getEarlyIncentive(keyId: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/keys/${encodeURIComponent(keyId)}/early-incentive`
+ );
+ return normalizeEarlyIncentive(response.data.data);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const earlyIncentiveService = new EarlyIncentiveService();
+
+export function fetchEarlyIncentive(keyId: string): Promise {
+ return earlyIncentiveService.getEarlyIncentive(keyId);
+}
From 5caf7182cd3877f62f3295f0d463928a413cdf13 Mon Sep 17 00:00:00 2001
From: trustosaretin
Date: Wed, 21 Oct 2026 04:33:11 +0100
Subject: [PATCH 3/5] feat(keys): add secondary market price history chart with
volume bars (#1234)
---
.../common/SecondaryMarketPriceChart.tsx | 183 ++++++++++++++++++
src/hooks/useSecondaryPrices.ts | 15 ++
src/services/secondaryPrices.service.ts | 90 +++++++++
3 files changed, 288 insertions(+)
create mode 100644 src/components/common/SecondaryMarketPriceChart.tsx
create mode 100644 src/hooks/useSecondaryPrices.ts
create mode 100644 src/services/secondaryPrices.service.ts
diff --git a/src/components/common/SecondaryMarketPriceChart.tsx b/src/components/common/SecondaryMarketPriceChart.tsx
new file mode 100644
index 00000000..c31c82f1
--- /dev/null
+++ b/src/components/common/SecondaryMarketPriceChart.tsx
@@ -0,0 +1,183 @@
+import { useState } from 'react';
+import {
+ Bar,
+ CartesianGrid,
+ ComposedChart,
+ Line,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import type { ReactNode } from 'react';
+import Skeleton from '@/components/ui/skeleton';
+import { cn } from '@/lib/utils';
+import type {
+ SecondaryPricePoint,
+ SecondaryPriceRange,
+} from '@/services/secondaryPrices.service';
+
+interface SecondaryMarketPriceChartProps {
+ data?: SecondaryPricePoint[];
+ isLoading: boolean;
+ onRangeChange: (range: SecondaryPriceRange) => void;
+}
+
+const ranges: Array<{ value: SecondaryPriceRange; label: string }> = [
+ { value: '7d', label: '7D' },
+ { value: '30d', label: '30D' },
+ { value: 'all', label: 'All Time' },
+];
+
+const formatTime = (value: ReactNode) =>
+ new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: 'numeric',
+ }).format(new Date(String(value)));
+
+/**
+ * Secondary market price history chart (#1234).
+ *
+ * Plots trade price per fraction over time with volume bars underneath,
+ * plus a 7d / 30d / All Time range selector and an empty state for keys
+ * with no secondary market trades.
+ */
+export function SecondaryMarketPriceChart({
+ data = [],
+ isLoading,
+ onRangeChange,
+}: SecondaryMarketPriceChartProps) {
+ const [range, setRange] = useState('7d');
+
+ const handleRangeChange = (next: SecondaryPriceRange) => {
+ setRange(next);
+ onRangeChange(next);
+ };
+
+ return (
+
+
+
+ Secondary market price
+
+
+ {ranges.map(option => (
+
+ ))}
+
+
+
+ {isLoading ? (
+
+
+
+ ) : data.length === 0 ? (
+
+ No secondary market trades yet
+
+ ) : (
+
+
+
+
+
+ `${value} XLM`}
+ />
+
+ [
+ name === 'price'
+ ? `${String(value)} XLM`
+ : String(value),
+ name === 'price' ? 'Price' : 'Volume',
+ ]}
+ />
+
+
+
+
+
+ )}
+
+ );
+}
+
+export default SecondaryMarketPriceChart;
diff --git a/src/hooks/useSecondaryPrices.ts b/src/hooks/useSecondaryPrices.ts
new file mode 100644
index 00000000..b5c9ff88
--- /dev/null
+++ b/src/hooks/useSecondaryPrices.ts
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import {
+ fetchSecondaryPrices,
+ type SecondaryPriceRange,
+} from '@/services/secondaryPrices.service';
+
+export function useSecondaryPrices(keyId: string, range: SecondaryPriceRange) {
+ return useQuery({
+ queryKey: queryKeys.creators.secondaryPrices(keyId, range),
+ queryFn: () => fetchSecondaryPrices(keyId, range),
+ enabled: !!keyId,
+ retry: false,
+ });
+}
diff --git a/src/services/secondaryPrices.service.ts b/src/services/secondaryPrices.service.ts
new file mode 100644
index 00000000..3b41949e
--- /dev/null
+++ b/src/services/secondaryPrices.service.ts
@@ -0,0 +1,90 @@
+import { BaseApiService, type APIResponse } from './api.service';
+
+export type SecondaryPriceRange = '7d' | '30d' | 'all';
+
+/** One secondary-market trade point from GET /keys/:keyId/secondary_prices. */
+export interface SecondaryPricePoint {
+ /** Trade time as a unix-ms timestamp. */
+ timestamp: number;
+ /** Trade price per fraction, in XLM. */
+ price: number;
+ /** Trade size (fractions) for the transaction. */
+ volume: number;
+}
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return typeof value === 'object' && value !== null
+ ? (value as UnknownRecord)
+ : {};
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function normalizeTimestamp(value: unknown): number {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value < 1_000_000_000_000 ? value * 1_000 : value;
+ }
+ if (typeof value === 'string') {
+ const numericValue = Number(value);
+ if (Number.isFinite(numericValue) && value.trim() !== '') {
+ return normalizeTimestamp(numericValue);
+ }
+ const parsed = new Date(value).getTime();
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return Date.now();
+}
+
+export function normalizeSecondaryPricePoint(
+ value: unknown
+): SecondaryPricePoint {
+ const raw = asRecord(value);
+ return {
+ timestamp: normalizeTimestamp(
+ raw.timestamp ?? raw.createdAt ?? raw.tradedAt ?? raw.time
+ ),
+ price: asNumber(raw.price ?? raw.pricePerFraction ?? raw.tradePrice),
+ volume: asNumber(raw.volume ?? raw.quantity ?? raw.amount ?? raw.size),
+ };
+}
+
+export function normalizeSecondaryPrices(value: unknown): SecondaryPricePoint[] {
+ const data =
+ asRecord(value).prices ?? asRecord(value).trades ?? asRecord(value).data ?? value;
+ const rows = Array.isArray(data) ? data : [];
+
+ return rows
+ .map(normalizeSecondaryPricePoint)
+ .sort((a, b) => a.timestamp - b.timestamp);
+}
+
+class SecondaryPricesService extends BaseApiService {
+ async getSecondaryPrices(
+ keyId: string,
+ range: SecondaryPriceRange = '7d'
+ ): Promise {
+ try {
+ const response = await this.api.get>(
+ `/keys/${encodeURIComponent(keyId)}/secondary_prices`,
+ { params: { range } }
+ );
+ return normalizeSecondaryPrices(response.data.data);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const secondaryPricesService = new SecondaryPricesService();
+
+export function fetchSecondaryPrices(
+ keyId: string,
+ range: SecondaryPriceRange = '7d'
+): Promise {
+ return secondaryPricesService.getSecondaryPrices(keyId, range);
+}
From d7b4ef7d505211e95bfd1b3e03db8e75ebfea162 Mon Sep 17 00:00:00 2001
From: trustosaretin
Date: Wed, 21 Oct 2026 04:33:16 +0100
Subject: [PATCH 4/5] feat(issuer): add verification tier badges on key cards
and profile header (#1233)
---
.../common/CreatorPublicProfileHeader.tsx | 5 ++
.../common/IssuerVerificationBadge.tsx | 89 +++++++++++++++++++
src/components/common/KeyCard.tsx | 5 ++
src/hooks/useIssuerVerification.ts | 12 +++
src/services/issuerVerification.service.ts | 80 +++++++++++++++++
5 files changed, 191 insertions(+)
create mode 100644 src/components/common/IssuerVerificationBadge.tsx
create mode 100644 src/hooks/useIssuerVerification.ts
create mode 100644 src/services/issuerVerification.service.ts
diff --git a/src/components/common/CreatorPublicProfileHeader.tsx b/src/components/common/CreatorPublicProfileHeader.tsx
index a22efce8..f1bbc563 100644
--- a/src/components/common/CreatorPublicProfileHeader.tsx
+++ b/src/components/common/CreatorPublicProfileHeader.tsx
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import VerifiedBadge from '@/components/common/VerifiedBadge';
+import IssuerVerificationBadge from '@/components/common/IssuerVerificationBadge';
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
import CreatorProfileSocialLinks from '@/components/common/CreatorProfileSocialLinks';
import CreatorBio from '@/components/common/CreatorBio';
@@ -81,6 +82,10 @@ const CreatorPublicProfileHeader: React.FC = ({
{displayName}
{isVerified && }
+
{displayHandle && (
diff --git a/src/components/common/IssuerVerificationBadge.tsx b/src/components/common/IssuerVerificationBadge.tsx
new file mode 100644
index 00000000..7bbc3897
--- /dev/null
+++ b/src/components/common/IssuerVerificationBadge.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { ShieldCheck } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { Tooltip } from '@/components/ui/tooltip';
+import type {
+ IssuerVerification,
+ IssuerVerificationTier,
+} from '@/services/issuerVerification.service';
+import { useIssuerVerification } from '@/hooks/useIssuerVerification';
+
+const TIER_LABELS: Record = {
+ basic: 'Basic',
+ enhanced: 'Enhanced',
+ institutional: 'Institutional',
+};
+
+const TIER_COLOURS: Record = {
+ basic: 'border-white/15 bg-white/[0.06] text-white/70',
+ enhanced: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
+ institutional: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
+};
+
+export interface IssuerVerificationBadgeProps {
+ /** Issuer wallet address used for GET /issuers/:wallet/verification. */
+ wallet?: string | null;
+ /** Pre-fetched verification; skips the network call when provided. */
+ verification?: IssuerVerification | null;
+ /** Show the expiry date in a tooltip (issuer profile header). */
+ showExpiryTooltip?: boolean;
+ className?: string;
+}
+
+/**
+ * Issuer verification tier badge (#1233).
+ *
+ * Renders Basic (grey) / Enhanced (blue) / Institutional (gold) chips for
+ * verified issuers. Nothing is shown for unverified issuers or when the
+ * verification lookup fails.
+ */
+export const IssuerVerificationBadge: React.FC = ({
+ wallet,
+ verification,
+ showExpiryTooltip = false,
+ className,
+}) => {
+ const { data: fetched, isLoading } = useIssuerVerification(
+ wallet ?? '',
+ !verification && !!wallet
+ );
+ const data = verification ?? fetched;
+
+ if (verification === undefined && isLoading) return null;
+ if (!data || !data.verified) return null;
+
+ const label = TIER_LABELS[data.tier] ?? TIER_LABELS.basic;
+
+ const badge = (
+
+
+ {label}
+
+ );
+
+ if (showExpiryTooltip) {
+ return (
+
+ {badge}
+
+ );
+ }
+
+ return badge;
+};
+
+export default IssuerVerificationBadge;
diff --git a/src/components/common/KeyCard.tsx b/src/components/common/KeyCard.tsx
index 5b53518c..24625122 100644
--- a/src/components/common/KeyCard.tsx
+++ b/src/components/common/KeyCard.tsx
@@ -10,6 +10,7 @@ import {
resolveCreatorKeyPriceStroops,
} from '@/utils/keyPriceDisplay.utils';
import AddToBatchButton from '@/components/common/AddToBatchButton';
+import IssuerVerificationBadge from '@/components/common/IssuerVerificationBadge';
import { cn } from '@/lib/utils';
export interface KeyCardProps {
@@ -84,6 +85,10 @@ export const KeyCard: React.FC = ({ creator, className }) => {
{handle}
)}
+
diff --git a/src/hooks/useIssuerVerification.ts b/src/hooks/useIssuerVerification.ts
new file mode 100644
index 00000000..ea496930
--- /dev/null
+++ b/src/hooks/useIssuerVerification.ts
@@ -0,0 +1,12 @@
+import { useQuery } from '@tanstack/react-query';
+import { fetchIssuerVerification } from '@/services/issuerVerification.service';
+
+export function useIssuerVerification(wallet: string, enabled = true) {
+ return useQuery({
+ queryKey: ['issuerVerification', wallet],
+ queryFn: () => fetchIssuerVerification(wallet),
+ enabled: enabled && !!wallet,
+ retry: false,
+ staleTime: 5 * 60_000,
+ });
+}
diff --git a/src/services/issuerVerification.service.ts b/src/services/issuerVerification.service.ts
new file mode 100644
index 00000000..9a8129f6
--- /dev/null
+++ b/src/services/issuerVerification.service.ts
@@ -0,0 +1,80 @@
+import { BaseApiService, type APIResponse } from './api.service';
+
+export type IssuerVerificationTier = 'basic' | 'enhanced' | 'institutional';
+
+/** Issuer verification returned by GET /issuers/:wallet/verification. */
+export interface IssuerVerification {
+ tier: IssuerVerificationTier;
+ /** ISO expiry date for the verification tier, when the API reports it. */
+ expiresAt: string | null;
+ verified: boolean;
+}
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return typeof value === 'object' && value !== null
+ ? (value as UnknownRecord)
+ : {};
+}
+
+const TIER_ALIASES: Record = {
+ basic: 'basic',
+ tier1: 'basic',
+ '1': 'basic',
+ enhanced: 'enhanced',
+ tier2: 'enhanced',
+ '2': 'enhanced',
+ institutional: 'institutional',
+ tier3: 'institutional',
+ '3': 'institutional',
+};
+
+function normalizeTier(value: unknown): IssuerVerificationTier | null {
+ const key = String(value ?? '').trim().toLowerCase();
+ return TIER_ALIASES[key] ?? null;
+}
+
+function normalizeExpiry(value: unknown): string | null {
+ if (typeof value === 'string' && value.trim() !== '') return value;
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return new Date(value < 1_000_000_000_000 ? value * 1_000 : value).toISOString();
+ }
+ return null;
+}
+
+export function normalizeIssuerVerification(value: unknown): IssuerVerification {
+ const raw = asRecord(value);
+ const tier =
+ normalizeTier(raw.tier ?? raw.verificationTier ?? raw.level) ?? 'basic';
+ const verifiedFlag = raw.verified ?? raw.isVerified;
+ const verified =
+ typeof verifiedFlag === 'boolean' ? verifiedFlag : tier !== null;
+
+ return {
+ tier,
+ expiresAt: normalizeExpiry(raw.expiresAt ?? raw.expires_at ?? raw.expiry),
+ verified,
+ };
+}
+
+class IssuerVerificationService extends BaseApiService {
+ async getIssuerVerification(wallet: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/issuers/${encodeURIComponent(wallet)}/verification`
+ );
+ return normalizeIssuerVerification(response.data.data);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const issuerVerificationService = new IssuerVerificationService();
+
+export function fetchIssuerVerification(
+ wallet: string
+): Promise {
+ return issuerVerificationService.getIssuerVerification(wallet);
+}
From ef704e1e8756446a8438dd49a0dbff5b249ef2c1 Mon Sep 17 00:00:00 2001
From: trustosaretin
Date: Wed, 21 Oct 2026 04:33:22 +0100
Subject: [PATCH 5/5] feat(keys): add payment schedule timeline and wire
charts/chips on key detail (#1235)
---
.../common/PaymentScheduleTimeline.tsx | 171 ++++++++++++++++++
src/hooks/usePaymentSchedule.ts | 15 ++
src/pages/CreatorDetailPage.tsx | 102 ++++++++++-
src/services/paymentSchedule.service.ts | 114 ++++++++++++
4 files changed, 394 insertions(+), 8 deletions(-)
create mode 100644 src/components/common/PaymentScheduleTimeline.tsx
create mode 100644 src/hooks/usePaymentSchedule.ts
create mode 100644 src/services/paymentSchedule.service.ts
diff --git a/src/components/common/PaymentScheduleTimeline.tsx b/src/components/common/PaymentScheduleTimeline.tsx
new file mode 100644
index 00000000..68b21732
--- /dev/null
+++ b/src/components/common/PaymentScheduleTimeline.tsx
@@ -0,0 +1,171 @@
+import React from 'react';
+import { cn } from '@/lib/utils';
+import type {
+ PaymentInstalment,
+ PaymentSchedule,
+ PaymentInstalmentStatus,
+} from '@/services/paymentSchedule.service';
+
+const STATUS_STYLES: Record = {
+ paid: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300',
+ pending: 'border-white/15 bg-white/[0.06] text-white/70',
+ overdue: 'border-rose-400/35 bg-rose-400/10 text-rose-300',
+};
+
+const STATUS_LABELS: Record = {
+ paid: 'Paid',
+ pending: 'Pending',
+ overdue: 'Overdue',
+};
+
+function formatDate(value: string): string {
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime())
+ ? value
+ : parsed.toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+}
+
+function formatAmount(value: number): string {
+ return `${value.toLocaleString(undefined, {
+ maximumFractionDigits: 2,
+ })} XLM`;
+}
+
+function formatCountdown(dueDate: string): string | null {
+ const diffMs = new Date(dueDate).getTime() - Date.now();
+ if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
+ const days = Math.ceil(diffMs / 86_400_000);
+ if (days > 1) return `in ${days} days`;
+ const hours = Math.max(1, Math.ceil(diffMs / 3_600_000));
+ return `in ${hours}h`;
+}
+
+export interface PaymentScheduleTimelineProps {
+ schedule?: PaymentSchedule | null;
+ isLoading?: boolean;
+ className?: string;
+}
+
+/**
+ * Instalment payment schedule timeline (#1235).
+ *
+ * Lists every scheduled instalment with due date, amount and status chip,
+ * a cumulative repayment progress bar, and a countdown on the next due
+ * instalment. Renders nothing when the key has no payment schedule.
+ */
+export const PaymentScheduleTimeline: React.FC = ({
+ schedule,
+ isLoading = false,
+ className,
+}) => {
+ if (isLoading || !schedule || schedule.instalments.length === 0) return null;
+
+ const now = Date.now();
+ const upcoming = schedule.instalments.find(
+ item => item.status !== 'paid' && new Date(item.dueDate).getTime() >= now
+ );
+ const nextDueNumber = upcoming?.number ?? null;
+ const progressPercent =
+ schedule.totalAmount > 0
+ ? Math.min(100, Math.round((schedule.paidAmount / schedule.totalAmount) * 100))
+ : 0;
+
+ return (
+
+
+ Payment schedule
+
+
+
+
+ Repaid
+
+ {formatAmount(schedule.paidAmount)} / {formatAmount(schedule.totalAmount)}{' '}
+ ({progressPercent}%)
+
+
+
+
+
+
+ {schedule.instalments.map((item: PaymentInstalment) => {
+ const isNextDue = item.number === nextDueNumber;
+ const countdown = isNextDue ? formatCountdown(item.dueDate) : null;
+
+ return (
+ -
+
+
+ Instalment {item.number}
+ {isNextDue && (
+
+ Next due{countdown ? ` · ${countdown}` : ''}
+
+ )}
+
+
+
+
+ ·
+
+
+ {formatAmount(item.amount)}
+
+
+
+
+ {STATUS_LABELS[item.status] ?? STATUS_LABELS.pending}
+
+
+ );
+ })}
+
+
+ );
+};
+
+export default PaymentScheduleTimeline;
diff --git a/src/hooks/usePaymentSchedule.ts b/src/hooks/usePaymentSchedule.ts
new file mode 100644
index 00000000..f2e51cd5
--- /dev/null
+++ b/src/hooks/usePaymentSchedule.ts
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+import { queryKeys } from '@/lib/queryKeys';
+import {
+ fetchPaymentSchedule,
+ type PaymentSchedule,
+} from '@/services/paymentSchedule.service';
+
+export function usePaymentSchedule(keyId: string) {
+ return useQuery({
+ queryKey: queryKeys.creators.paymentSchedule(keyId),
+ queryFn: () => fetchPaymentSchedule(keyId),
+ enabled: !!keyId,
+ retry: false,
+ });
+}
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index 4e5de26c..71a2420b 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -65,6 +65,13 @@ import CoCreatorSection from '@/components/creator/CoCreatorSection';
import ShareTwitterButton from '@/components/common/ShareTwitterButton';
import { PriceHistoryChart } from '@/components/common/PriceHistoryChart';
import { BondingCurvePriceChart } from '@/components/common/BondingCurvePriceChart';
+import SecondaryMarketPriceChart from '@/components/common/SecondaryMarketPriceChart';
+import EarlyInvestorBonusChip from '@/components/common/EarlyInvestorBonusChip';
+import PaymentScheduleTimeline from '@/components/common/PaymentScheduleTimeline';
+import { useEarlyIncentive } from '@/hooks/useEarlyIncentive';
+import { useSecondaryPrices } from '@/hooks/useSecondaryPrices';
+import { usePaymentSchedule } from '@/hooks/usePaymentSchedule';
+import type { SecondaryPriceRange } from '@/services/secondaryPrices.service';
import type { PriceHistoryInterval } from '@/services/course.service';
import TradeDialog from '@/components/common/TradeDialog';
import SpreadIndicator from '@/components/common/SpreadIndicator';
@@ -136,6 +143,11 @@ function CreatorDetailPageContent() {
const [tradeSide, setTradeSide] = useState('buy');
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const [interval, setInterval] = useState('24h');
+ const [priceChartTab, setPriceChartTab] = useState<'primary' | 'secondary'>(
+ 'primary'
+ );
+ const [secondaryRange, setSecondaryRange] =
+ useState('7d');
const {
data: creator,
@@ -242,6 +254,21 @@ function CreatorDetailPageContent() {
isError: isPerformanceBondError,
} = usePerformanceBond(id || '');
+ const {
+ data: earlyIncentive,
+ isLoading: isEarlyIncentiveLoading,
+ } = useEarlyIncentive(id || '');
+
+ const {
+ data: secondaryPrices,
+ isLoading: isSecondaryPricesLoading,
+ } = useSecondaryPrices(id || '', secondaryRange);
+
+ const {
+ data: paymentSchedule,
+ isLoading: isPaymentScheduleLoading,
+ } = usePaymentSchedule(id || '');
+
const performanceBond =
performanceBondData ?? creator?.performanceBond ?? null;
@@ -643,14 +670,60 @@ function CreatorDetailPageContent() {
{keyFreeze && }
-
-
-
+ {/* Primary / secondary market price tabs (#1234) */}
+
+
+
+
+
+ {priceChartTab === 'primary' ? (
+
+
+
+ ) : (
+
+
+
+ )}
{/* Live bonding-curve price chart (#1115) */}
@@ -691,6 +764,13 @@ function CreatorDetailPageContent() {
/>
+
+
+
+
{isKeyDeprecated(creator) && (
@@ -740,6 +820,12 @@ function CreatorDetailPageContent() {
)}
+
+
{creator.publicLaunchDate && hasValidPublicLaunchDate && (
{isPublicLaunchPending
diff --git a/src/services/paymentSchedule.service.ts b/src/services/paymentSchedule.service.ts
new file mode 100644
index 00000000..4f9dae7c
--- /dev/null
+++ b/src/services/paymentSchedule.service.ts
@@ -0,0 +1,114 @@
+import { BaseApiService, type APIResponse } from './api.service';
+
+export type PaymentInstalmentStatus = 'paid' | 'pending' | 'overdue';
+
+/** One scheduled instalment on a payment schedule. */
+export interface PaymentInstalment {
+ /** 1-based instalment number. */
+ number: number;
+ /** ISO due date for the instalment. */
+ dueDate: string;
+ /** Instalment amount, in XLM. */
+ amount: number;
+ status: PaymentInstalmentStatus;
+}
+
+/** Instalment-based repayment schedule for a key/invoice. */
+export interface PaymentSchedule {
+ instalments: PaymentInstalment[];
+ /** Cumulative repaid amount, in XLM. */
+ paidAmount: number;
+ /** Total scheduled amount, in XLM. */
+ totalAmount: number;
+}
+
+type UnknownRecord = Record;
+
+function asRecord(value: unknown): UnknownRecord {
+ return typeof value === 'object' && value !== null
+ ? (value as UnknownRecord)
+ : {};
+}
+
+function asNumber(value: unknown, fallback = 0): number {
+ const parsed = typeof value === 'number' ? value : Number(value);
+ return Number.isFinite(parsed) ? parsed : fallback;
+}
+
+function normalizeDueDate(value: unknown): string {
+ if (typeof value === 'string' && value.trim() !== '') return value;
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return new Date(
+ value < 1_000_000_000_000 ? value * 1_000 : value
+ ).toISOString();
+ }
+ return new Date().toISOString();
+}
+
+function normalizeStatus(value: unknown, dueDate: string): PaymentInstalmentStatus {
+ const status = String(value ?? '').trim().toLowerCase();
+ if (status === 'paid' || status === 'complete' || status === 'completed') {
+ return 'paid';
+ }
+ if (status === 'overdue' || status === 'late') return 'overdue';
+ if (status === 'pending' || status === 'scheduled' || status === 'due') {
+ return 'pending';
+ }
+ // Derive from the due date when the API omits a status.
+ return new Date(dueDate).getTime() < Date.now() ? 'overdue' : 'pending';
+}
+
+export function normalizePaymentInstalment(
+ value: unknown,
+ index: number
+): PaymentInstalment {
+ const raw = asRecord(value);
+ const dueDate = normalizeDueDate(raw.dueDate ?? raw.due_date ?? raw.dueAt);
+ return {
+ number: Math.max(1, Math.floor(asNumber(raw.number ?? raw.instalmentNumber ?? index + 1))),
+ dueDate,
+ amount: asNumber(raw.amount ?? raw.value ?? raw.total),
+ status: normalizeStatus(raw.status ?? raw.state, dueDate),
+ };
+}
+
+export function normalizePaymentSchedule(value: unknown): PaymentSchedule {
+ const raw = asRecord(value);
+ const data =
+ raw.instalments ?? raw.schedule ?? raw.payments ?? raw.data ?? value;
+ const rows = Array.isArray(data) ? data : [];
+
+ const instalments = rows
+ .map((row, index) => normalizePaymentInstalment(row, index))
+ .sort((a, b) => a.number - b.number);
+
+ const totalAmount =
+ asNumber(raw.totalAmount ?? raw.total_amount) ||
+ instalments.reduce((sum, item) => sum + item.amount, 0);
+ const paidAmount =
+ asNumber(raw.paidAmount ?? raw.paid_amount) ||
+ instalments
+ .filter(item => item.status === 'paid')
+ .reduce((sum, item) => sum + item.amount, 0);
+
+ return { instalments, paidAmount, totalAmount };
+}
+
+class PaymentScheduleService extends BaseApiService {
+ async getPaymentSchedule(keyId: string): Promise {
+ try {
+ const response = await this.api.get>(
+ `/keys/${encodeURIComponent(keyId)}/payment-schedule`
+ );
+ return normalizePaymentSchedule(response.data.data);
+ } catch (error) {
+ throw this.handleError(error);
+ }
+ }
+}
+
+export const paymentScheduleService = new PaymentScheduleService();
+
+export function fetchPaymentSchedule(keyId: string): Promise {
+ return paymentScheduleService.getPaymentSchedule(keyId);
+}