Skip to content
Merged
26 changes: 22 additions & 4 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -116,6 +118,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
};

const isRecentlyActive = (creator.volume24h ?? 0) > 0;
const keyPriceDisplay = formatCreatorKeyPriceDisplay(creator);

const handleCopyLink = () => {
const url = `${window.location.origin}/creator/${creator.id}`;
Expand Down Expand Up @@ -259,6 +262,10 @@ const CreatorCard: React.FC<CreatorCardProps> = ({

<CreatorBio bio={creator.description} variant="card" className="mt-2" />

{creator.nextDropAt ? (
<CreatorDropCountdown nextDropAt={creator.nextDropAt} />
) : null}

{creator.socialHandle ? (
<div className="marketplace-label-muted mt-2 flex items-center gap-1.5 text-xs">
<LinkIcon className="creator-action-icon text-amber-500/70" />
Expand All @@ -282,7 +289,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
</div>

<div className="mt-3 flex flex-wrap gap-2">
<MiniStatChip label="Price" value={`${formatNumber(creator.price)} ETH`} />
<MiniStatChip label="Price" value={keyPriceDisplay} />
<MiniStatChip
label="Category"
value={creator.category || 'General'}
Expand Down Expand Up @@ -338,7 +345,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
className="inline-block size-3 shrink-0 animate-spin rounded-full border-2 border-amber-400/30 border-t-amber-400"
/>
)}
<span>{`${formatNumber(creator.price)} ETH`}</span>
<span>{keyPriceDisplay}</span>
{isPriceRefreshing && (
<span className="sr-only">Refreshing price</span>
)}
Expand All @@ -356,7 +363,18 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
</div>
<CreatorListRowDivider className="mt-4 mb-2" />

<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* #354: grouped purchase actions share one sr-only label for context. */}
<div
role="group"
aria-labelledby={`creator-card-actions-label-${creator.id}`}
className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<span
id={`creator-card-actions-label-${creator.id}`}
className="sr-only"
>
Purchase actions for {creator.title}
</span>
<NetworkFeeHint className="shrink-0" />
<AsyncButton
onClick={handleBuy}
Expand Down
38 changes: 38 additions & 0 deletions src/components/common/CreatorDropCountdown.tsx
Original file line number Diff line number Diff line change
@@ -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<CreatorDropCountdownProps> = ({
nextDropAt,
className,
}) => {
const { label, isLive } = useDropCountdown(nextDropAt);

if (label == null) return null;

return (
<p
role="status"
aria-live="polite"
className={cn(
'mt-2 inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[0.65rem] font-semibold uppercase tracking-[0.12em]',
isLive
? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-200'
: 'border-amber-400/25 bg-amber-500/10 text-amber-200/90',
className
)}
>
<span className="text-white/50">{isLive ? '' : 'Next drop in '}</span>
<span className="tabular-nums">{label}</span>
</p>
);
};

export default CreatorDropCountdown;
13 changes: 13 additions & 0 deletions src/components/common/TradeDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import NetworkFeeHint from '@/components/common/NetworkFeeHint';
import { TRADE_FEE_ESTIMATE } from '@/constants/fees';
Expand All @@ -23,6 +24,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> | void;
isSubmitting?: boolean;
Expand All @@ -33,6 +36,7 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
side,
creatorName,
availableHoldings,
keyPriceStroops,
onOpenChange,
onConfirm,
isSubmitting = false,
Expand Down Expand Up @@ -91,6 +95,15 @@ const TradeDialog: React.FC<TradeDialogProps> = ({
</DialogDescription>
</DialogHeader>

{side === 'buy' && keyPriceStroops != null && (
<p className="text-sm text-white/60">
Unit price:{' '}
<span className="font-semibold text-amber-300/90 tabular-nums">
{formatDisplayKeyPrice(keyPriceStroops)}
</span>
</p>
)}

<div className="space-y-2">
<div className="text-sm text-white/70">Amount</div>
<input
Expand Down
2 changes: 2 additions & 0 deletions src/constants/stellar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Stellar / Soroban native asset precision: 1 XLM = 10^7 stroops. */
export const STROOPS_PER_XLM = 10_000_000;
29 changes: 29 additions & 0 deletions src/hooks/useDropCountdown.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
23 changes: 23 additions & 0 deletions src/hooks/usePrefersReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -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;
}
59 changes: 41 additions & 18 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { LayoutGroup, motion } from 'framer-motion';
import { courseService, type Course } from '@/services/course.service';
import SkipToContent from '@/components/common/SkipToContent';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -43,6 +44,9 @@ import {
CREATOR_CARD_ENTRY_CLASS,
creatorCardEntryStyle,
} 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, ChevronDown, RefreshCw } from 'lucide-react';
import ClearedFiltersEmptyState from '@/components/common/ClearedFiltersEmptyState';
import CreatorListPagination from '@/components/common/CreatorListPagination';
Expand Down Expand Up @@ -86,6 +90,8 @@ const DEMO_CREATORS: Course[] = [
title: 'Alex Rivers',
description: 'Digital Artist & Illustrator',
price: 0.05,
priceStroops: 500_000,
nextDropAt: new Date(Date.now() + 86_400_000).toISOString(),
creatorShareSupply: 120,
instructorId: 'arivers',
category: 'Art',
Expand All @@ -99,6 +105,7 @@ const DEMO_CREATORS: Course[] = [
title: 'Sarah Chen',
description: 'Solidity Developer',
price: 0.12,
priceStroops: 1_200_000,
creatorShareSupply: 64,
instructorId: 'schen_dev',
category: 'Tech',
Expand All @@ -125,6 +132,8 @@ const DEMO_CREATORS: Course[] = [
title: 'Elena Vance',
description: 'UI/UX Designer',
price: 0.04,
priceStroops: 400_000,
nextDropAt: new Date(Date.now() + 3_600_000).toISOString(),
creatorShareSupply: 150,
instructorId: 'evance_design',
category: 'Design',
Expand Down Expand Up @@ -237,6 +246,7 @@ function LandingPage() {
const [tradeSide, setTradeSide] = useState<TradeSide>('buy');
const [tradeDialogOpen, setTradeDialogOpen] = useState(false);
const [tradeSubmitting, setTradeSubmitting] = useState(false);
const prefersReducedMotion = usePrefersReducedMotion();
const [sortOption, setSortOption] = useState<SortOption>(() => {
if (typeof window === 'undefined') return 'featured';
const saved = window.localStorage.getItem(
Expand Down Expand Up @@ -390,12 +400,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(
Expand Down Expand Up @@ -656,22 +669,31 @@ function LandingPage() {
className="self-start"
/>
)}
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{pagedCreators.map((creator, index) => (
// #300: staggered entry animation; the
// helper no-ops on prefers-reduced-motion.
<div
key={creator.id}
className={CREATOR_CARD_ENTRY_CLASS}
style={creatorCardEntryStyle(index)}
>
<CreatorCard
creator={creator}
isPriceRefreshing={isPriceRefreshing}
/>
</div>
))}
</div>
<LayoutGroup>
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
{pagedCreators.map((creator, index) => (
// #300: staggered entry animation; the
// helper no-ops on prefers-reduced-motion.
// #355: layout transition when sort order changes.
<motion.div
key={creator.id}
layout={!prefersReducedMotion}
transition={
CREATOR_LIST_SORT_LAYOUT_TRANSITION
}
className={CREATOR_CARD_ENTRY_CLASS}
style={creatorCardEntryStyle(index, {
prefersReducedMotion,
})}
>
<CreatorCard
creator={creator}
isPriceRefreshing={isPriceRefreshing}
/>
</motion.div>
))}
</div>
</LayoutGroup>
<CreatorListPagination
page={safePage}
totalPages={totalPages}
Expand Down Expand Up @@ -964,6 +986,7 @@ function LandingPage() {
side={tradeSide}
creatorName="Alex Rivers"
availableHoldings={featuredHoldings}
keyPriceStroops={resolveCreatorKeyPriceStroops(DEMO_CREATORS[0])}
isSubmitting={tradeSubmitting}
onOpenChange={setTradeDialogOpen}
onConfirm={handleConfirmTrade}
Expand Down
4 changes: 4 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
28 changes: 28 additions & 0 deletions src/utils/__tests__/dropCountdown.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading
Loading