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
23 changes: 22 additions & 1 deletion src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useRef, useState, useCallback } from 'react';
import { Link } from 'react-router';
import { useAccount } from 'wagmi';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
Expand Down Expand Up @@ -225,6 +226,9 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
runPurchaseAttempt();
}, [isConnected, isNetworkMismatch, expectedChainName, displayCreatorName, runPurchaseAttempt]);

const resolvedHolderCount =
creator.holderCount ?? creator.holdersCount ?? creator.holders ?? creator.creatorShareSupply;

return (
<div
ref={cardRef}
Expand Down Expand Up @@ -314,7 +318,13 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
id={`creator-name-${creator.id}`}
className="font-jakarta text-lg font-bold text-white"
>
{displayCreatorName}
<Link
to={`/creator/${creator.id}`}
data-testid="creator-profile-link"
className="hover:underline"
>
{displayCreatorName}
</Link>
</h3>
<VerifiedBadge
verified={Boolean(creator.isVerified)}
Expand Down Expand Up @@ -502,6 +512,17 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
truncateValue={false}
valueClassName="font-grotesque text-base font-black text-amber-400"
/>
{resolvedHolderCount != null && (
<CardMetaRow
label="Holders"
value={
<span data-testid="creator-card-holders">
{`${resolvedHolderCount} holders`}
</span>
}
valueClassName="text-white/75"
/>
)}
</div>
<CreatorListRowDivider className="my-4" />
<CreatorSocialLinksList
Expand Down
17 changes: 3 additions & 14 deletions src/components/common/TransactionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { ChevronDown, ChevronUp, ArrowUpRight, ArrowDownRight, Minus } from 'luc
import { cn } from '@/lib/utils';
import { formatRelativeTime } from '@/utils/time.utils';
import { formatCreatorHandle } from '@/utils/handleDisplay.utils';
import TransactionTypeBadge from '@/components/common/TransactionTypeBadge';


export interface Transaction {
id: string;
Expand Down Expand Up @@ -132,17 +134,6 @@ const TransactionHistory: React.FC<TransactionHistoryProps> = ({
}
};

const getTransactionTypeLabel = (type: Transaction['type']) => {
switch (type) {
case 'buy':
return 'Buy';
case 'sell':
return 'Sell';
default:
return 'Unknown';
}
};

return (
<section className="rounded-2xl border border-white/10 bg-white/5 p-6 md:p-8">
<div className="mb-6 flex items-center justify-between">
Expand Down Expand Up @@ -186,9 +177,7 @@ const TransactionHistory: React.FC<TransactionHistoryProps> = ({
<div className="flex min-w-0 flex-1 items-center gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-white">
{getTransactionTypeLabel(tx.type)}
</span>
<TransactionTypeBadge type={tx.type} />
<span className="text-white/40">•</span>
<span
className="text-white/90"
Expand Down
42 changes: 42 additions & 0 deletions src/components/common/TransactionTypeBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';
import { cn } from '@/lib/utils';

export type TransactionType = 'buy' | 'sell' | string;

interface TransactionTypeBadgeProps {
type?: TransactionType | null;
className?: string;
}

export const TransactionTypeBadge: React.FC<TransactionTypeBadgeProps> = ({
type,
className,
}) => {
const normalizedType = typeof type === 'string' ? type.toLowerCase() : '';

let label = 'Unknown';
let colorClass = 'bg-gray-500/10 text-gray-400 border-gray-500/20';

if (normalizedType === 'buy') {
label = 'Buy';
colorClass = 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20';
} else if (normalizedType === 'sell') {
label = 'Sell';
colorClass = 'bg-rose-500/10 text-rose-400 border-rose-500/20';
}

return (
<span
data-testid={`transaction-type-badge-${normalizedType || 'unknown'}`}
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold',
colorClass,
className
)}
>
{label}
</span>
);
};

export default TransactionTypeBadge;
118 changes: 118 additions & 0 deletions src/components/common/__tests__/CreatorCard.props.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, expect, it, vi } from 'vitest';
import CreatorCard from '@/components/common/CreatorCard';
import type { Course } from '@/services/course.service';

vi.mock('wagmi', () => ({
useAccount: () => ({ isConnected: false }),
useConnect: () => ({ connectAsync: vi.fn(), connectors: [] }),
useReconnect: () => ({ reconnectAsync: vi.fn(), connectors: [] }),
}));

vi.mock('@/hooks/useNetworkMismatch', () => ({
useNetworkMismatch: () => ({
isMismatch: false,
expectedChainName: 'Stellar Testnet',
}),
}));

vi.mock('@/hooks/useTransactionTelemetry', () => ({
useTransactionTelemetry: () => vi.fn(),
}));

vi.mock('@/utils/useSystemTheme', () => ({
useSystemTheme: () => ({ isDarkMode: true }),
}));

const baseCreator: Course = {
id: 'alex-rivers',
title: 'Alex Rivers',
description: 'Creates tech tutorials.',
price: 5.5,
holderCount: 120,
instructorId: 'ARivers',
category: 'Tech',
level: 'BEGINNER',
};

describe('CreatorCard props rendering', () => {
it('renders price 5.5 XLM and 120 holders correctly from props', () => {
render(
<MemoryRouter>
<CreatorCard creator={baseCreator} />
</MemoryRouter>
);

const priceBadge = screen.getByTestId('creator-card-price-badge');
expect(priceBadge).toHaveTextContent('5.50 XLM');

const holdersSpan = screen.getByTestId('creator-card-holders');
expect(holdersSpan).toHaveTextContent('120 holders');
}, 15000);

it('renders price 0 as "0.00 XLM" without crashing', () => {
const creator: Course = {
...baseCreator,
price: 0,
priceStroops: 0,
};

expect(() => {
render(
<MemoryRouter>
<CreatorCard creator={creator} />
</MemoryRouter>
);
}).not.toThrow();

const priceBadge = screen.getByTestId('creator-card-price-badge');
expect(priceBadge).toHaveTextContent('0.00 XLM');
}, 15000);

it('renders 0 holders as "0 holders" without crashing', () => {
const creator: Course = {
...baseCreator,
holderCount: 0,
};

expect(() => {
render(
<MemoryRouter>
<CreatorCard creator={creator} />
</MemoryRouter>
);
}).not.toThrow();

const holdersSpan = screen.getByTestId('creator-card-holders');
expect(holdersSpan).toHaveTextContent('0 holders');
}, 15000);

it('asserts the card links to the correct creator profile route', () => {
render(
<MemoryRouter>
<CreatorCard creator={baseCreator} />
</MemoryRouter>
);

const link = screen.getByTestId('creator-profile-link');
expect(link).toHaveAttribute('href', '/creator/alex-rivers');
}, 15000);

it('renders a dash placeholder for a null price', () => {
const creator: Course = {
...baseCreator,
price: null as unknown as number,
priceStroops: undefined,
};

render(
<MemoryRouter>
<CreatorCard creator={creator} />
</MemoryRouter>
);

const priceBadge = screen.getByTestId('creator-card-price-badge');
expect(priceBadge).toHaveTextContent('—');
}, 15000);
});
55 changes: 55 additions & 0 deletions src/components/common/__tests__/TransactionTypeBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import TransactionTypeBadge from '../TransactionTypeBadge';

describe('TransactionTypeBadge', () => {
it('renders a green badge with label "Buy" for buy transaction type', () => {
expect(() => {
render(<TransactionTypeBadge type="buy" />);
}).not.toThrow();

const badge = screen.getByTestId('transaction-type-badge-buy');
expect(badge).toHaveTextContent('Buy');
expect(badge.className).toContain('emerald');
});

it('renders a red badge with label "Sell" for sell transaction type', () => {
expect(() => {
render(<TransactionTypeBadge type="sell" />);
}).not.toThrow();

const badge = screen.getByTestId('transaction-type-badge-sell');
expect(badge).toHaveTextContent('Sell');
expect(badge.className).toContain('rose');
});

it('renders a grey badge with label "Unknown" for unknown transaction type', () => {
expect(() => {
render(<TransactionTypeBadge type="other" />);
}).not.toThrow();

const badge = screen.getByTestId('transaction-type-badge-other');
expect(badge).toHaveTextContent('Unknown');
expect(badge.className).toContain('gray');
});

it('handles null type gracefully without throwing errors', () => {
expect(() => {
render(<TransactionTypeBadge type={null} />);
}).not.toThrow();

const badge = screen.getByTestId('transaction-type-badge-unknown');
expect(badge).toHaveTextContent('Unknown');
expect(badge.className).toContain('gray');
});

it('handles undefined type gracefully without throwing errors', () => {
expect(() => {
render(<TransactionTypeBadge type={undefined} />);
}).not.toThrow();

const badge = screen.getByTestId('transaction-type-badge-unknown');
expect(badge).toHaveTextContent('Unknown');
expect(badge.className).toContain('gray');
});
});
9 changes: 9 additions & 0 deletions src/hooks/useFollowingCreators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import type { Course } from '@/services/course.service';

export function useFollowingCreators() {
return useQuery<Course[]>({
queryKey: ['following-creators'],
queryFn: async () => [],
});
}
3 changes: 1 addition & 2 deletions src/pages/CreatorDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,8 @@ import { bpsToPercent, formatNumber } from '@/utils/numberFormat.utils';
import { resolveCreatorKeyPriceStroops, formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
import KeyDetailPageErrorBoundary from '@/components/common/KeyDetailPageErrorBoundary';
import { ApiError } from '@/services/api.service';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
import { useKeyHolders } from '@/hooks/useKeyHolders';
import KeyHolderList from '@/components/common/KeyHolderList';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';

function CreatorDetailPageContent() {
const { id } = useParams<{ id: string }>();
Expand Down
63 changes: 63 additions & 0 deletions src/pages/FollowingPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Link } from 'react-router';
import { Users } from 'lucide-react';
import CreatorCard from '@/components/common/CreatorCard';
import { useFollowingCreators } from '@/hooks/useFollowingCreators';

export default function FollowingPage() {
const { data: creators = [], isLoading, isFetched } = useFollowingCreators();

return (
<main className="mx-auto max-w-7xl px-6 py-16">
<h1 className="mb-8 font-jakarta text-2xl font-bold text-white">
Following
</h1>

{isLoading && (
<div
data-testid="following-page-loading"
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
data-testid="following-skeleton"
className="h-64 animate-pulse rounded-2xl bg-white/5"
/>
))}
</div>
)}

{!isLoading && isFetched && creators.length === 0 && (
<div
data-testid="following-empty-state"
className="flex flex-col items-center justify-center rounded-2xl border border-white/10 bg-white/5 p-12 text-center"
>
<div className="mb-4 flex size-14 items-center justify-center rounded-full bg-white/10 text-amber-400">
<Users className="size-7" aria-hidden="true" />
</div>
<p className="mb-6 max-w-md text-base text-white/70">
You are not following anyone yet — discover creators on the marketplace
</p>
<Link
to="/creators"
data-testid="browse-marketplace-button"
className="inline-flex items-center justify-center rounded-xl bg-amber-400 px-6 py-3 text-sm font-bold text-slate-950 transition-colors hover:bg-amber-300"
>
Browse Marketplace
</Link>
</div>
)}

{!isLoading && creators.length > 0 && (
<div
data-testid="following-creators-list"
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"
>
{creators.map(creator => (
<CreatorCard key={creator.id} creator={creator} />
))}
</div>
)}
</main>
);
}
Loading
Loading