From 0d60d2e72bbbe9214bad2b6dadbc6b40986f82f7 Mon Sep 17 00:00:00 2001 From: codellins Date: Fri, 29 May 2026 16:12:28 +0100 Subject: [PATCH 1/8] Add Stellar stroops constant and key price display helper. Introduces reusable formatting that converts stroop amounts to XLM with a stroops fallback for sub-threshold values (#353). --- src/constants/stellar.ts | 2 + src/utils/keyPriceDisplay.utils.ts | 60 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/constants/stellar.ts create mode 100644 src/utils/keyPriceDisplay.utils.ts diff --git a/src/constants/stellar.ts b/src/constants/stellar.ts new file mode 100644 index 0000000..4178340 --- /dev/null +++ b/src/constants/stellar.ts @@ -0,0 +1,2 @@ +/** Stellar / Soroban native asset precision: 1 XLM = 10^7 stroops. */ +export const STROOPS_PER_XLM = 10_000_000; diff --git a/src/utils/keyPriceDisplay.utils.ts b/src/utils/keyPriceDisplay.utils.ts new file mode 100644 index 0000000..9251fb1 --- /dev/null +++ b/src/utils/keyPriceDisplay.utils.ts @@ -0,0 +1,60 @@ +import { STROOPS_PER_XLM } from '@/constants/stellar'; +import { formatNumber } from '@/utils/numberFormat.utils'; + +export interface CreatorKeyPriceFields { + priceStroops?: number | null; + /** Legacy demo field interpreted as whole XLM when stroops are absent. */ + price?: number | null; +} + +/** + * Resolves the on-chain key price in stroops from explicit stroops or legacy XLM. + */ +export function resolveCreatorKeyPriceStroops( + creator: CreatorKeyPriceFields +): number | null { + if (creator.priceStroops != null && Number.isFinite(creator.priceStroops)) { + return creator.priceStroops; + } + if (creator.price != null && Number.isFinite(creator.price)) { + return Math.round(creator.price * STROOPS_PER_XLM); + } + return null; +} + +/** + * Formats a stroop amount for display as XLM, falling back to stroops when the + * XLM value would round to zero at the default display precision. + */ +export function formatDisplayKeyPrice( + stroops: number | null | undefined +): string { + if (stroops == null || !Number.isFinite(stroops)) { + return '—'; + } + + const xlm = stroops / STROOPS_PER_XLM; + const xlmFormatted = formatNumber(xlm, { + maximumFractionDigits: 4, + minimumFractionDigits: 0, + }); + + const parsedXlm = Number.parseFloat(xlmFormatted.replace(/,/g, '')); + const xlmWouldRoundToZero = + stroops > 0 && (!Number.isFinite(parsedXlm) || parsedXlm === 0); + + if (xlmWouldRoundToZero) { + return `${stroops.toLocaleString()} stroops`; + } + + return `${xlmFormatted} XLM`; +} + +/** + * Convenience helper for creator records that may store stroops or legacy XLM. + */ +export function formatCreatorKeyPriceDisplay( + creator: CreatorKeyPriceFields +): string { + return formatDisplayKeyPrice(resolveCreatorKeyPriceStroops(creator)); +} From d14d322f3e66b875942d59ca4844f4e9c05dd967 Mon Sep 17 00:00:00 2001 From: codellins Date: Fri, 29 May 2026 16:12:32 +0100 Subject: [PATCH 2/8] Add unit tests for key price display formatting. Covers XLM display, stroops fallback, and creator record resolution (#353). --- .../__tests__/keyPriceDisplay.utils.test.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 src/utils/__tests__/keyPriceDisplay.utils.test.ts diff --git a/src/utils/__tests__/keyPriceDisplay.utils.test.ts b/src/utils/__tests__/keyPriceDisplay.utils.test.ts new file mode 100644 index 0000000..e1f0e01 --- /dev/null +++ b/src/utils/__tests__/keyPriceDisplay.utils.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + formatCreatorKeyPriceDisplay, + formatDisplayKeyPrice, + resolveCreatorKeyPriceStroops, +} from '../keyPriceDisplay.utils'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; + +describe('resolveCreatorKeyPriceStroops', () => { + it('prefers explicit stroops', () => { + expect( + resolveCreatorKeyPriceStroops({ priceStroops: 42, price: 1 }) + ).toBe(42); + }); + + it('derives stroops from legacy XLM price', () => { + expect(resolveCreatorKeyPriceStroops({ price: 0.05 })).toBe( + 0.05 * STROOPS_PER_XLM + ); + }); +}); + +describe('formatDisplayKeyPrice', () => { + it('formats amounts above the XLM display threshold in XLM', () => { + expect(formatDisplayKeyPrice(500_000)).toBe('0.05 XLM'); + }); + + it('falls back to stroops when XLM would round to zero', () => { + expect(formatDisplayKeyPrice(1)).toBe('1 stroops'); + }); + + it('returns placeholder for missing values', () => { + expect(formatDisplayKeyPrice(null)).toBe('—'); + }); +}); + +describe('formatCreatorKeyPriceDisplay', () => { + it('formats from stroops on a creator record', () => { + expect(formatCreatorKeyPriceDisplay({ priceStroops: 1_200_000 })).toBe( + '0.12 XLM' + ); + }); +}); From 141c4d414fafae5b682e42d3c8fa63b69abcffc8 Mon Sep 17 00:00:00 2001 From: codellins Date: Fri, 29 May 2026 16:12:33 +0100 Subject: [PATCH 3/8] Extend Course model with stroops price and next drop timestamp. Adds optional priceStroops and nextDropAt fields for Stellar marketplace data (#353, #362). --- src/services/course.service.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 52d69d6..36f2290 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -7,6 +7,10 @@ export interface Course { title: string; description: string; price: number; + /** On-chain key price in stroops (preferred over legacy `price`). */ + priceStroops?: number; + /** ISO timestamp for the next scheduled drop, when applicable. */ + nextDropAt?: string; creatorShareSupply?: number; instructorId: string; thumbnail?: string; From 539095d391a284a20d8a940e8116200f240f3f77 Mon Sep 17 00:00:00 2001 From: codellins Date: Fri, 29 May 2026 16:12:34 +0100 Subject: [PATCH 4/8] Show creator key prices in XLM on marketplace cards. Uses the shared stroops formatter for stat chips and the key price row (#353). --- src/components/common/CreatorCard.tsx | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index ad810fa..b05a3d6 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -14,7 +14,9 @@ import { import RecentActivityBadge from '@/components/common/RecentActivityBadge'; import toast from 'react-hot-toast'; import showToast from '@/utils/toast.util'; -import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; +import { formatCompactNumber } from '@/utils/numberFormat.utils'; +import { formatCreatorKeyPriceDisplay } from '@/utils/keyPriceDisplay.utils'; +import CreatorDropCountdown from '@/components/common/CreatorDropCountdown'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; import { AsyncButton } from '@/components/ui/async-button'; import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; @@ -116,6 +118,7 @@ const CreatorCard: React.FC = ({ }; const isRecentlyActive = (creator.volume24h ?? 0) > 0; + const keyPriceDisplay = formatCreatorKeyPriceDisplay(creator); const handleCopyLink = () => { const url = `${window.location.origin}/creator/${creator.id}`; @@ -259,6 +262,10 @@ const CreatorCard: React.FC = ({ + {creator.nextDropAt ? ( + + ) : null} + {creator.socialHandle ? (
@@ -282,7 +289,7 @@ const CreatorCard: React.FC = ({
- + = ({ className="inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400" /> )} - {`${formatNumber(creator.price)} ETH`} + {keyPriceDisplay} {isPriceRefreshing && ( Refreshing price )} @@ -356,7 +363,17 @@ const CreatorCard: React.FC = ({
-
+
+ + Actions for {creator.title} + Date: Fri, 29 May 2026 16:12:35 +0100 Subject: [PATCH 5/8] Show unit key price on the buy confirmation dialog. Passes stroops from demo data into TradeDialog for buy-side confirmation (#353). --- src/components/common/TradeDialog.tsx | 13 ++++++ src/pages/LandingPage.tsx | 61 +++++++++++++++++++-------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/src/components/common/TradeDialog.tsx b/src/components/common/TradeDialog.tsx index 6e0d5bd..7ee0b3c 100644 --- a/src/components/common/TradeDialog.tsx +++ b/src/components/common/TradeDialog.tsx @@ -11,6 +11,7 @@ import { } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { formatNumber } from '@/utils/numberFormat.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import PercentageBadge from '@/components/common/PercentageBadge'; export type TradeSide = 'buy' | 'sell'; @@ -20,6 +21,8 @@ export interface TradeDialogProps { side: TradeSide; creatorName: string; availableHoldings: number; + /** Per-key price in stroops, shown on the buy confirmation step. */ + keyPriceStroops?: number | null; onOpenChange: (open: boolean) => void; onConfirm: (amount: number) => Promise | void; isSubmitting?: boolean; @@ -30,6 +33,7 @@ const TradeDialog: React.FC = ({ side, creatorName, availableHoldings, + keyPriceStroops, onOpenChange, onConfirm, isSubmitting = false, @@ -84,6 +88,15 @@ const TradeDialog: React.FC = ({ + {side === 'buy' && keyPriceStroops != null && ( +

+ Unit price:{' '} + + {formatDisplayKeyPrice(keyPriceStroops)} + +

+ )} +
Amount
('buy'); const [tradeDialogOpen, setTradeDialogOpen] = useState(false); const [tradeSubmitting, setTradeSubmitting] = useState(false); + const prefersReducedMotion = usePrefersReducedMotion(); const [sortOption, setSortOption] = useState(() => { if (typeof window === 'undefined') return 'featured'; const saved = window.localStorage.getItem( @@ -367,12 +376,15 @@ function LandingPage() { .includes(trimmedSearchQuery.toLowerCase()) ); const sorted = [...filtered]; + const priceOf = (creator: Course) => + resolveCreatorKeyPriceStroops(creator) ?? 0; + switch (sortOption) { case 'price-asc': - sorted.sort((a, b) => (a.price ?? 0) - (b.price ?? 0)); + sorted.sort((a, b) => priceOf(a) - priceOf(b)); break; case 'price-desc': - sorted.sort((a, b) => (b.price ?? 0) - (a.price ?? 0)); + sorted.sort((a, b) => priceOf(b) - priceOf(a)); break; case 'supply-desc': sorted.sort( @@ -630,22 +642,34 @@ function LandingPage() { className="self-start" /> )} -
- {pagedCreators.map((creator, index) => ( - // #300: staggered entry animation; the - // helper no-ops on prefers-reduced-motion. -
- -
- ))} -
+ +
+ {pagedCreators.map((creator, index) => ( + // #300: staggered entry animation; the + // helper no-ops on prefers-reduced-motion. + // #355: layout transition when sort order changes. + + + + ))} +
+
Date: Fri, 29 May 2026 16:13:06 +0100 Subject: [PATCH 6/8] Add drop countdown utilities and live creator drop badge. Formats time remaining until the next drop, updates every second, and shows a drop-live label at zero (#362). --- .../common/CreatorDropCountdown.tsx | 38 +++++++++++++++++++ src/hooks/useDropCountdown.ts | 29 ++++++++++++++ src/hooks/usePrefersReducedMotion.ts | 23 +++++++++++ .../__tests__/dropCountdown.utils.test.ts | 28 ++++++++++++++ src/utils/dropCountdown.utils.ts | 32 ++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 src/components/common/CreatorDropCountdown.tsx create mode 100644 src/hooks/useDropCountdown.ts create mode 100644 src/hooks/usePrefersReducedMotion.ts create mode 100644 src/utils/__tests__/dropCountdown.utils.test.ts create mode 100644 src/utils/dropCountdown.utils.ts diff --git a/src/components/common/CreatorDropCountdown.tsx b/src/components/common/CreatorDropCountdown.tsx new file mode 100644 index 0000000..d54ab13 --- /dev/null +++ b/src/components/common/CreatorDropCountdown.tsx @@ -0,0 +1,38 @@ +import { cn } from '@/lib/utils'; +import { useDropCountdown } from '@/hooks/useDropCountdown'; + +interface CreatorDropCountdownProps { + nextDropAt: string; + className?: string; +} + +/** + * Live countdown for a scheduled creator drop; switches to a drop-live label at zero. + */ +const CreatorDropCountdown: React.FC = ({ + nextDropAt, + className, +}) => { + const { label, isLive } = useDropCountdown(nextDropAt); + + if (label == null) return null; + + return ( +

+ {isLive ? '' : 'Next drop in '} + {label} +

+ ); +}; + +export default CreatorDropCountdown; diff --git a/src/hooks/useDropCountdown.ts b/src/hooks/useDropCountdown.ts new file mode 100644 index 0000000..c8eadb0 --- /dev/null +++ b/src/hooks/useDropCountdown.ts @@ -0,0 +1,29 @@ +import { useEffect, useMemo, useState } from 'react'; +import { getDropCountdownState } from '@/utils/dropCountdown.utils'; + +/** + * Tracks time remaining until `targetIso`, updating every second until live. + */ +export function useDropCountdown(targetIso: string | null | undefined) { + const targetMs = useMemo(() => { + if (targetIso == null) return null; + const parsed = new Date(targetIso).getTime(); + return Number.isNaN(parsed) ? null : parsed; + }, [targetIso]); + + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + if (targetMs == null) return; + setNowMs(Date.now()); + const id = window.setInterval(() => setNowMs(Date.now()), 1000); + return () => window.clearInterval(id); + }, [targetMs]); + + return useMemo(() => { + if (targetMs == null) { + return { isLive: false, label: null as string | null }; + } + return getDropCountdownState(targetMs, nowMs); + }, [targetMs, nowMs]); +} diff --git a/src/hooks/usePrefersReducedMotion.ts b/src/hooks/usePrefersReducedMotion.ts new file mode 100644 index 0000000..b7d6251 --- /dev/null +++ b/src/hooks/usePrefersReducedMotion.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react'; + +const QUERY = '(prefers-reduced-motion: reduce)'; + +/** + * Subscribes to the user's reduced-motion preference. + */ +export function usePrefersReducedMotion(): boolean { + const [prefersReducedMotion, setPrefersReducedMotion] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia(QUERY).matches; + }); + + useEffect(() => { + const media = window.matchMedia(QUERY); + const onChange = () => setPrefersReducedMotion(media.matches); + onChange(); + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + + return prefersReducedMotion; +} diff --git a/src/utils/__tests__/dropCountdown.utils.test.ts b/src/utils/__tests__/dropCountdown.utils.test.ts new file mode 100644 index 0000000..39f6b8e --- /dev/null +++ b/src/utils/__tests__/dropCountdown.utils.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { + formatDropTimeRemaining, + getDropCountdownState, +} from '../dropCountdown.utils'; + +describe('formatDropTimeRemaining', () => { + it('includes days, hours, minutes, and seconds when relevant', () => { + const ms = + 2 * 86_400_000 + 3 * 3_600_000 + 4 * 60_000 + 5_000; + expect(formatDropTimeRemaining(ms)).toBe('2d 3h 4m 5s'); + }); +}); + +describe('getDropCountdownState', () => { + it('returns drop-live when the target time has passed', () => { + expect(getDropCountdownState(1_000, 2_000)).toEqual({ + isLive: true, + label: 'Drop live', + }); + }); + + it('returns a countdown label while time remains', () => { + const state = getDropCountdownState(65_000, 0); + expect(state.isLive).toBe(false); + expect(state.label).toBe('1m 5s'); + }); +}); diff --git a/src/utils/dropCountdown.utils.ts b/src/utils/dropCountdown.utils.ts new file mode 100644 index 0000000..9be74d1 --- /dev/null +++ b/src/utils/dropCountdown.utils.ts @@ -0,0 +1,32 @@ +/** + * Formats milliseconds remaining until a drop as a compact human-readable string. + */ +export function formatDropTimeRemaining(msRemaining: number): string { + const totalSeconds = Math.max(0, Math.floor(msRemaining / 1000)); + const days = Math.floor(totalSeconds / 86_400); + const hours = Math.floor((totalSeconds % 86_400) / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (days > 0 || hours > 0) parts.push(`${hours}h`); + if (days > 0 || hours > 0 || minutes > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + + return parts.join(' '); +} + +export function getDropCountdownState( + targetMs: number, + nowMs: number +): { isLive: boolean; label: string } { + const remaining = targetMs - nowMs; + if (remaining <= 0) { + return { isLive: true, label: 'Drop live' }; + } + return { + isLive: false, + label: formatDropTimeRemaining(remaining), + }; +} From a4a44c0d7a71be8654d34fa4a29c9544f57a225b Mon Sep 17 00:00:00 2001 From: codellins Date: Fri, 29 May 2026 16:13:22 +0100 Subject: [PATCH 7/8] Add sr-only label for creator card purchase action group. Groups the buy row with a visually hidden label so screen readers announce shared context before each control (#354). --- src/components/common/CreatorCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index b05a3d6..19fae1a 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -363,6 +363,7 @@ const CreatorCard: React.FC = ({
+ {/* #354: grouped purchase actions share one sr-only label for context. */}
= ({ id={`creator-card-actions-label-${creator.id}`} className="sr-only" > - Actions for {creator.title} + Purchase actions for {creator.title} Date: Fri, 29 May 2026 16:13:23 +0100 Subject: [PATCH 8/8] Animate marketplace creator list reorder on sort change. Uses layout transitions with a shared spring config and disables motion when prefers-reduced-motion is set (#355). --- src/pages/LandingPage.tsx | 10 ++++------ src/utils/creatorListSortTransition.ts | 7 +++++++ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 src/utils/creatorListSortTransition.ts diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index eb0fcf8..e7375f9 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -46,6 +46,7 @@ import { } from '@/utils/cardEntryAnimation.utils'; import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; import { usePrefersReducedMotion } from '@/hooks/usePrefersReducedMotion'; +import { CREATOR_LIST_SORT_LAYOUT_TRANSITION } from '@/utils/creatorListSortTransition'; import { AlertCircle, RefreshCw } from 'lucide-react'; import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState'; import CreatorListPagination from '@/components/common/CreatorListPagination'; @@ -651,12 +652,9 @@ function LandingPage() {