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/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 ( + + + ); +}; + +export default EarlyInvestorBonusChip; 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 = ( + + + ); + + 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/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 ( +
  1. +
    +

    + Instalment {item.number} + {isNextDue && ( + + Next due{countdown ? ` · ${countdown}` : ''} + + )} +

    +

    + + + + {formatAmount(item.amount)} + +

    +
    + + {STATUS_LABELS[item.status] ?? STATUS_LABELS.pending} + +
  2. + ); + })} +
+
+ ); +}; + +export default PaymentScheduleTimeline; 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/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/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/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/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/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) => 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/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); +} 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); +} 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); +} 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); +}