Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions src/components/common/ActivityItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ import {
Vote,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/utils/time.utils';
import { useRelativeTime } from '@/hooks/useRelativeTime';
import { buildStellarExpertTxUrl, truncateTxHash } from '@/constants/stellar';
import { env } from '@/utils/env.utils';
import type { WalletActivityTrade, WalletActivityEventType } from '@/services/walletActivity.service';
import type {
WalletActivityTrade,
WalletActivityEventType,
} from '@/services/walletActivity.service';

// ─── Icon + colour config per event type ──────────────────────────────────────

Expand All @@ -27,35 +30,54 @@ function getEventConfig(type: WalletActivityEventType): EventConfig {
switch (type) {
case 'buy':
return {
icon: <ArrowUpRight className="size-4 text-emerald-400" aria-hidden="true" />,
icon: (
<ArrowUpRight
className="size-4 text-emerald-400"
aria-hidden="true"
/>
),
iconBg: 'bg-emerald-500/10',
accentBorder: 'border-l-emerald-500',
label: 'Buy',
};
case 'sell':
return {
icon: <ArrowDownRight className="size-4 text-rose-400" aria-hidden="true" />,
icon: (
<ArrowDownRight
className="size-4 text-rose-400"
aria-hidden="true"
/>
),
iconBg: 'bg-rose-500/10',
accentBorder: 'border-l-rose-500',
label: 'Sell',
};
case 'stake':
return {
icon: <Coins className="size-4 text-amber-400" aria-hidden="true" />,
icon: (
<Coins className="size-4 text-amber-400" aria-hidden="true" />
),
iconBg: 'bg-amber-500/10',
accentBorder: 'border-l-amber-500',
label: 'Stake',
};
case 'unstake':
return {
icon: <TrendingDown className="size-4 text-orange-400" aria-hidden="true" />,
icon: (
<TrendingDown
className="size-4 text-orange-400"
aria-hidden="true"
/>
),
iconBg: 'bg-orange-500/10',
accentBorder: 'border-l-orange-500',
label: 'Unstake',
};
case 'claim':
return {
icon: <Gift className="size-4 text-violet-400" aria-hidden="true" />,
icon: (
<Gift className="size-4 text-violet-400" aria-hidden="true" />
),
iconBg: 'bg-violet-500/10',
accentBorder: 'border-l-violet-500',
label: 'Claim Reward',
Expand All @@ -78,12 +100,15 @@ function buildDetailParts(entry: WalletActivityTrade): string[] {
switch (entry.type) {
case 'buy':
case 'sell':
if (entry.amount != null) parts.push(`${entry.amount} key${entry.amount !== 1 ? 's' : ''}`);
if (entry.price != null) parts.push(`${entry.price.toFixed(4)} XLM / key`);
if (entry.amount != null)
parts.push(`${entry.amount} key${entry.amount !== 1 ? 's' : ''}`);
if (entry.price != null)
parts.push(`${entry.price.toFixed(4)} XLM / key`);
break;
case 'stake':
case 'unstake':
if (entry.amount != null) parts.push(`${entry.amount} key${entry.amount !== 1 ? 's' : ''}`);
if (entry.amount != null)
parts.push(`${entry.amount} key${entry.amount !== 1 ? 's' : ''}`);
break;
case 'claim':
if (entry.price != null) parts.push(`${entry.price.toFixed(4)} XLM`);
Expand All @@ -95,8 +120,8 @@ function buildDetailParts(entry: WalletActivityTrade): string[] {
entry.voteChoice === 'for'
? '✓ For'
: entry.voteChoice === 'against'
? '✗ Against'
: '— Abstain';
? '✗ Against'
: '— Abstain';
parts.push(choiceLabel);
}
break;
Expand Down Expand Up @@ -147,6 +172,7 @@ export interface ActivityItemProps {
const ActivityItem: React.FC<ActivityItemProps> = ({ entry, className }) => {
const config = getEventConfig(entry.type);
const details = buildDetailParts(entry);
const relativeTime = useRelativeTime(entry.timestamp);

return (
<div
Expand Down Expand Up @@ -209,7 +235,7 @@ const ActivityItem: React.FC<ActivityItemProps> = ({ entry, className }) => {
className="text-xs text-white/50"
title={new Date(entry.timestamp).toLocaleString()}
>
{formatRelativeTime(entry.timestamp)}
{relativeTime}
</span>
<ExplorerLink txHash={entry.txHash} />
</div>
Expand Down
80 changes: 47 additions & 33 deletions src/components/common/CreatorActivityFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ArrowDownRight, ArrowUpRight, History } from 'lucide-react';
import { useCreatorActivityFeed } from '@/hooks/useCreatorActivityFeed';
import { creatorActivityService } from '@/services/creatorActivity.service';
import { formatRelativeTime } from '@/utils/time.utils';
import { useRelativeTime } from '@/hooks/useRelativeTime';
import { formatCreatorHandle } from '@/utils/handleDisplay.utils';

export interface CreatorActivityFeedProps {
Expand All @@ -10,6 +10,48 @@ export interface CreatorActivityFeedProps {

const SKELETON_ROW_COUNT = 3;

interface ActivityRowProps {
trade: ReturnType<typeof useCreatorActivityFeed>['trades'][number];
}

function ActivityRow({ trade }: ActivityRowProps) {
const relativeTime = useRelativeTime(trade.timestamp);

return (
<div
key={trade.id}
data-testid={`creator-activity-item-${trade.id}`}
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5">
{trade.type === 'buy' ? (
<ArrowUpRight className="size-4 text-emerald-400" />
) : (
<ArrowDownRight className="size-4 text-rose-400" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{trade.type === 'buy' ? 'Buy' : 'Sell'}
</span>
<span className="text-white/40">•</span>
<span className="text-white/90">
{formatCreatorHandle(trade.traderHandle)}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{trade.amount} keys</span>
<span className="text-white/30">•</span>
<span>{trade.price} XLM</span>
<span className="text-white/30">•</span>
<span>{relativeTime}</span>
</div>
</div>
</div>
);
}

function ActivityFeedSkeletonRows() {
return (
<div data-testid="creator-activity-feed-skeleton" className="space-y-2">
Expand Down Expand Up @@ -40,7 +82,9 @@ function ActivityFeedSkeletonRows() {
* the issue spec.
* settled, trades.length > 0 -> real rows.
*/
const CreatorActivityFeed: React.FC<CreatorActivityFeedProps> = ({ creatorId }) => {
const CreatorActivityFeed: React.FC<CreatorActivityFeedProps> = ({
creatorId,
}) => {
const { trades, isLoading } = useCreatorActivityFeed(creatorId, id =>
creatorActivityService.getCreatorActivity(id)
);
Expand Down Expand Up @@ -68,37 +112,7 @@ const CreatorActivityFeed: React.FC<CreatorActivityFeedProps> = ({ creatorId })
return (
<div data-testid="creator-activity-feed" className="space-y-2">
{trades.map(trade => (
<div
key={trade.id}
data-testid={`creator-activity-item-${trade.id}`}
className="flex items-center gap-4 rounded-xl border border-white/10 bg-white/[0.02] p-4"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-white/5">
{trade.type === 'buy' ? (
<ArrowUpRight className="size-4 text-emerald-400" />
) : (
<ArrowDownRight className="size-4 text-rose-400" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{trade.type === 'buy' ? 'Buy' : 'Sell'}
</span>
<span className="text-white/40">•</span>
<span className="text-white/90">
{formatCreatorHandle(trade.traderHandle)}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-white/50">
<span>{trade.amount} keys</span>
<span className="text-white/30">•</span>
<span>{trade.price} XLM</span>
<span className="text-white/30">•</span>
<span>{formatRelativeTime(trade.timestamp)}</span>
</div>
</div>
</div>
<ActivityRow key={trade.id} trade={trade} />
))}
</div>
);
Expand Down
25 changes: 18 additions & 7 deletions src/components/home/CreatorSpotlight.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { Users, Star, ArrowRight } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef } from 'react';
import { Link } from 'react-router';
import { courseService, type Course } from '@/services/course.service';
import { queryKeys } from '@/lib/queryKeys';
import { STROOPS_PER_XLM } from '@/constants/stellar';
import { formatHolderCount } from '@/utils/numberFormat.utils';
import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils';
import { cn } from '@/lib/utils';
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
import { useFormatXlm } from '@/hooks/useFormatXlm';

/* ------------------------------------------------------------------ */
/* Skeleton */
Expand All @@ -27,10 +27,17 @@ function SpotlightSkeletonCard() {
<span className="sr-only">Loading spotlight creator</span>

{/* Rank badge placeholder */}
<div className={cn('mb-3 h-6 w-16 rounded-full', skeletonBlockClass)} />
<div
className={cn('mb-3 h-6 w-16 rounded-full', skeletonBlockClass)}
/>

{/* Avatar */}
<div className={cn('mb-4 aspect-square w-full rounded-xl', skeletonBlockClass)} />
<div
className={cn(
'mb-4 aspect-square w-full rounded-xl',
skeletonBlockClass
)}
/>

{/* Name */}
<div className={cn('mb-2 h-5 w-3/5', skeletonBlockClass)} />
Expand Down Expand Up @@ -65,9 +72,10 @@ interface SpotlightCreatorCardProps {

function SpotlightCreatorCard({ creator, rank }: SpotlightCreatorCardProps) {
const name = normalizeCreatorDisplayName(creator.title) || 'Unnamed creator';
const { format: formatXlm } = useFormatXlm();
const keyPrice =
creator.priceStroops != null
? (creator.priceStroops / STROOPS_PER_XLM).toFixed(2)
? formatXlm(creator.priceStroops)
: creator.price.toFixed(2);
const holderCount = creator.creatorShareSupply ?? 0;

Expand Down Expand Up @@ -112,7 +120,9 @@ function SpotlightCreatorCard({ creator, rank }: SpotlightCreatorCardProps) {
<span className="font-jakarta text-sm font-semibold text-gray-900">
{keyPrice}
</span>
<span className="font-jakarta text-[11px] text-gray-400">XLM</span>
<span className="font-jakarta text-[11px] text-gray-400">
XLM
</span>
</div>

{/* Holder count */}
Expand Down Expand Up @@ -143,7 +153,8 @@ export default function CreatorSpotlight() {

const { data: creators, isLoading } = useQuery({
queryKey: queryKeys.creators.list({ sort: 'volume_desc', limit: 3 }),
queryFn: () => courseService.getCourses({ sort: 'volume_desc', limit: 3 }),
queryFn: () =>
courseService.getCourses({ sort: 'volume_desc', limit: 3 }),
});

useEffect(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/components/home/TrendingCreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ type Props = { creator: Course & { walletAddress: string } };
import { formatHolderCount } from '@/utils/numberFormat.utils';
import { cn } from '@/lib/utils';
import { creatorCardSubtitleClampClass } from '@/utils/lineClamp.utils';
import { useFormatXlm } from '@/hooks/useFormatXlm';

export default function TrendingCreatorCard({ creator }: Props) {
const name = creator.title || 'Unnamed creator';
const avatar = makeBlockie(creator.walletAddress);
const { format: formatXlm } = useFormatXlm();
const priceXlm = creator.priceStroops
? (creator.priceStroops / 1e7).toFixed(2)
? formatXlm(creator.priceStroops)
: creator.price.toFixed(2);

return (
Expand Down
Loading