+
{Array.from({ length: 3 }).map((_, i) => (
))}
@@ -96,7 +165,7 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
{!isLoading && recent.length === 0 && (
No notifications yet
@@ -108,8 +177,8 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
key={notification.id}
data-testid={`notification-item-${notification.id}`}
className={cn(
- 'flex cursor-pointer flex-col items-start gap-1 px-3 py-2.5',
- !notification.read && 'bg-accent/40'
+ 'flex cursor-pointer flex-col items-start gap-1 px-3 py-2.5 hover:bg-white/10 focus:bg-white/10',
+ !notification.read && 'bg-white/[0.05]'
)}
onSelect={() =>
handleNotificationClick(
@@ -118,36 +187,30 @@ export function NotificationBell({ userId, className = '' }: NotificationBellPro
)
}
>
-
- {/* Unread indicator dot */}
- {!notification.read && (
-
- )}
+
+ {renderNotificationIcon(notification.type)}
{notification.message}
-
+
{formatRelativeTime(notification.createdAt)}
))}
-
+
void navigate('/notifications')}
>
View all
diff --git a/src/components/common/ShareTwitterButton.tsx b/src/components/common/ShareTwitterButton.tsx
new file mode 100644
index 00000000..5cd9df54
--- /dev/null
+++ b/src/components/common/ShareTwitterButton.tsx
@@ -0,0 +1,66 @@
+import { Share2 } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import {
+ buildShareTweetText,
+ buildTwitterIntentUrl,
+} from '@/utils/shareTwitter.utils';
+
+export interface ShareTwitterButtonProps {
+ creatorId: string;
+ creatorName: string;
+ priceXlm: string | number;
+ userAddress?: string | null;
+ userHoldingsCount?: number;
+ className?: string;
+}
+
+
+
+export function ShareTwitterButton({
+ creatorId,
+ creatorName,
+ priceXlm,
+ userAddress,
+ userHoldingsCount = 0,
+ className = '',
+}: ShareTwitterButtonProps) {
+ // Show Share button only when user is authenticated and holds at least 1 key
+ const isAuthenticated = Boolean(userAddress && userAddress.trim() !== '');
+ const isHolder = userHoldingsCount > 0;
+
+ if (!isAuthenticated || !isHolder) {
+ return null;
+ }
+
+ const origin =
+ typeof window !== 'undefined' && window.location?.origin
+ ? window.location.origin
+ : 'https://accesslayer.app';
+
+ const plainUrl = `${origin}/creator/${creatorId}`;
+ const referralUrl = userAddress ? `${plainUrl}?ref=${userAddress}` : plainUrl;
+
+ const tweetText = buildShareTweetText(creatorName, priceXlm, referralUrl);
+ const intentUrl = buildTwitterIntentUrl(tweetText);
+
+ const handleShareClick = () => {
+ if (typeof window !== 'undefined') {
+ window.open(intentUrl, '_blank', 'noopener,noreferrer');
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default ShareTwitterButton;
diff --git a/src/components/common/__tests__/NotificationBell.test.tsx b/src/components/common/__tests__/NotificationBell.test.tsx
index 2ffa4406..09d7a526 100644
--- a/src/components/common/__tests__/NotificationBell.test.tsx
+++ b/src/components/common/__tests__/NotificationBell.test.tsx
@@ -46,11 +46,13 @@ describe('NotificationBell', () => {
it('does not show the unread badge when unreadCount is 0', () => {
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -62,11 +64,13 @@ describe('NotificationBell', () => {
it('shows the unread badge with the correct count when unreadCount > 0', () => {
mockUseNotifications.mockReturnValue({
+ notifications: [makeNotification('n1')],
recent: [makeNotification('n1')],
unreadCount: 3,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -78,11 +82,13 @@ describe('NotificationBell', () => {
it('caps the badge display at 99+ when unreadCount > 99', () => {
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 150,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -96,11 +102,13 @@ describe('NotificationBell', () => {
it('gives the bell button an accessible label mentioning unread count', () => {
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 2,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -112,11 +120,13 @@ describe('NotificationBell', () => {
it('uses a generic label when unreadCount is 0', () => {
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -126,16 +136,18 @@ describe('NotificationBell', () => {
).toBeInTheDocument();
});
- // ββ Dropdown content βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+ // ββ Dropdown content & Drawer βββββββββββββββββββββββββββββββββββββββββββββ
- it('opens the dropdown when the bell is clicked', async () => {
+ it('opens the dropdown drawer when the bell is clicked', async () => {
const user = userEvent.setup();
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -147,14 +159,60 @@ describe('NotificationBell', () => {
).toBeInTheDocument();
});
+ it('calls markAllAsRead when drawer opens with unread items', async () => {
+ const user = userEvent.setup();
+ const markAllAsRead = vi.fn();
+ mockUseNotifications.mockReturnValue({
+ notifications: [makeNotification('n1')],
+ recent: [makeNotification('n1')],
+ unreadCount: 1,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ markAllAsRead,
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(markAllAsRead).toHaveBeenCalled();
+ });
+
+ it('renders distinct icons for trade_completed, lockup_expiring, and price_moved', async () => {
+ const user = userEvent.setup();
+ const recent = [
+ makeNotification('n1', { type: 'trade_completed', message: 'Trade executed' }),
+ makeNotification('n2', { type: 'lockup_expiring', message: 'Lockup expiring soon' }),
+ makeNotification('n3', { type: 'price_moved', message: 'Price surged 15%' }),
+ ];
+ mockUseNotifications.mockReturnValue({
+ notifications: recent,
+ recent,
+ unreadCount: 0,
+ isLoading: false,
+ isError: false,
+ markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
+ });
+
+ renderBell();
+ await user.click(screen.getByRole('button', { name: /notifications/i }));
+
+ expect(screen.getByTestId('icon-trade_completed')).toBeInTheDocument();
+ expect(screen.getByTestId('icon-lockup_expiring')).toBeInTheDocument();
+ expect(screen.getByTestId('icon-price_moved')).toBeInTheDocument();
+ });
+
it('shows the empty state when there are no notifications', async () => {
const user = userEvent.setup();
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -171,11 +229,13 @@ describe('NotificationBell', () => {
it('shows skeleton rows while loading', async () => {
const user = userEvent.setup();
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: true,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -197,11 +257,13 @@ describe('NotificationBell', () => {
makeNotification('n5'),
];
mockUseNotifications.mockReturnValue({
+ notifications: recent,
recent,
unreadCount: 5,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -218,11 +280,13 @@ describe('NotificationBell', () => {
const user = userEvent.setup();
const recent = [makeNotification('n1', { message: 'Alice followed you' })];
mockUseNotifications.mockReturnValue({
+ notifications: recent,
recent,
unreadCount: 1,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -238,11 +302,13 @@ describe('NotificationBell', () => {
const markAsRead = vi.fn();
const recent = [makeNotification('n1')];
mockUseNotifications.mockReturnValue({
+ notifications: recent,
recent,
unreadCount: 1,
isLoading: false,
isError: false,
markAsRead,
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -257,11 +323,13 @@ describe('NotificationBell', () => {
it('renders a "View all" link in the dropdown', async () => {
const user = userEvent.setup();
mockUseNotifications.mockReturnValue({
+ notifications: [],
recent: [],
unreadCount: 0,
isLoading: false,
isError: false,
markAsRead: vi.fn(),
+ markAllAsRead: vi.fn(),
});
renderBell();
@@ -282,6 +350,10 @@ describe('NotificationBell', () => {
// Start with count = 2
mockUseNotifications.mockReturnValue({
+ notifications: [
+ makeNotification('n1', { read: false }),
+ makeNotification('n2', { read: false }),
+ ],
recent: [
makeNotification('n1', { read: false }),
makeNotification('n2', { read: false }),
@@ -290,6 +362,7 @@ describe('NotificationBell', () => {
isLoading: false,
isError: false,
markAsRead,
+ markAllAsRead: vi.fn(),
});
const { rerender } = renderBell();
@@ -300,6 +373,10 @@ describe('NotificationBell', () => {
// Simulate the hook returning updated state after markAsRead is called
mockUseNotifications.mockReturnValue({
+ notifications: [
+ makeNotification('n1', { read: true }),
+ makeNotification('n2', { read: false }),
+ ],
recent: [
makeNotification('n1', { read: true }),
makeNotification('n2', { read: false }),
@@ -308,6 +385,7 @@ describe('NotificationBell', () => {
isLoading: false,
isError: false,
markAsRead,
+ markAllAsRead: vi.fn(),
});
rerender(
diff --git a/src/components/common/__tests__/ShareTwitterButton.test.tsx b/src/components/common/__tests__/ShareTwitterButton.test.tsx
new file mode 100644
index 00000000..2b1ad3d3
--- /dev/null
+++ b/src/components/common/__tests__/ShareTwitterButton.test.tsx
@@ -0,0 +1,113 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import ShareTwitterButton from '../ShareTwitterButton';
+import {
+ buildShareTweetText,
+ buildTwitterIntentUrl,
+} from '@/utils/shareTwitter.utils';
+
+describe('ShareTwitterButton helpers', () => {
+ it('builds pre-filled tweet text correctly', () => {
+ const tweet = buildShareTweetText(
+ 'Alex Rivers',
+ '0.05',
+ 'https://accesslayer.app/creator/creator-123?ref=G123'
+ );
+ expect(tweet).toBe(
+ 'Just bought Alex Rivers keys on AccessLayer at 0.05 XLM. Buy here: https://accesslayer.app/creator/creator-123?ref=G123'
+ );
+ });
+
+ it('builds Twitter intent URL with URI encoding', () => {
+ const text = 'Just bought Alex Rivers keys on AccessLayer at 0.05 XLM. Buy here: https://accesslayer.app/creator/creator-123';
+ const intentUrl = buildTwitterIntentUrl(text);
+ expect(intentUrl).toContain('https://twitter.com/intent/tweet?text=');
+ expect(intentUrl).toContain(encodeURIComponent(text));
+ });
+});
+
+describe('ShareTwitterButton Component', () => {
+ const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('does not render button when user is unauthenticated', () => {
+ render(
+
+ );
+
+ expect(
+ screen.queryByTestId('share-twitter-button')
+ ).not.toBeInTheDocument();
+ });
+
+ it('does not render button when user is authenticated but holds 0 keys', () => {
+ render(
+
+ );
+
+ expect(
+ screen.queryByTestId('share-twitter-button')
+ ).not.toBeInTheDocument();
+ });
+
+ it('renders Share to X button when user is authenticated and holds at least 1 key', () => {
+ render(
+
+ );
+
+ const btn = screen.getByTestId('share-twitter-button');
+ expect(btn).toBeInTheDocument();
+ expect(btn).toHaveTextContent('Share to X');
+ });
+
+ it('opens Twitter intent URL in a new tab with referral link when clicked', async () => {
+ const user = userEvent.setup();
+ const userAddress = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYSFIZGK63PZZVVJAB7';
+
+ render(
+
+ );
+
+ const btn = screen.getByTestId('share-twitter-button');
+ await user.click(btn);
+
+ expect(openSpy).toHaveBeenCalledTimes(1);
+ const openedUrl = openSpy.mock.calls[0][0] as string;
+ const target = openSpy.mock.calls[0][1];
+
+ expect(target).toBe('_blank');
+ expect(openedUrl).toContain('https://twitter.com/intent/tweet?text=');
+ expect(decodeURIComponent(openedUrl)).toContain(
+ 'Just bought Alex Rivers keys on AccessLayer at 0.05 XLM. Buy here:'
+ );
+ expect(decodeURIComponent(openedUrl)).toContain(`ref=${userAddress}`);
+ });
+});
diff --git a/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx
index 2229ab9f..4260c362 100644
--- a/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx
+++ b/src/components/common/__tests__/TradeDialog.sellPayoutDisplay.test.tsx
@@ -37,7 +37,7 @@ describe('TradeDialog β sell payout display (#692)', () => {
fireEvent.change(input, { target: { value: '2' } });
expect(screen.getByText(/Estimated proceeds/i)).toBeInTheDocument();
- expect(screen.getByText('0.1 XLM')).toBeInTheDocument();
+ expect(screen.getByText(/0\.10? XLM/)).toBeInTheDocument();
});
it('updates the displayed payout as the sell quantity changes', () => {
@@ -47,10 +47,10 @@ describe('TradeDialog β sell payout display (#692)', () => {
) as HTMLInputElement;
fireEvent.change(input, { target: { value: '1' } });
- expect(screen.getByText('0.05 XLM')).toBeInTheDocument();
+ expect(screen.getByText(/0\.05 XLM/)).toBeInTheDocument();
fireEvent.change(input, { target: { value: '4' } });
- expect(screen.getByText('0.2 XLM')).toBeInTheDocument();
+ expect(screen.getByText(/0\.20? XLM/)).toBeInTheDocument();
expect(screen.queryByText('0.05 XLM')).not.toBeInTheDocument();
});
diff --git a/src/components/common/__tests__/WalletConnectionPopover.test.tsx b/src/components/common/__tests__/WalletConnectionPopover.test.tsx
index e30c5f25..acdbbf2b 100644
--- a/src/components/common/__tests__/WalletConnectionPopover.test.tsx
+++ b/src/components/common/__tests__/WalletConnectionPopover.test.tsx
@@ -184,7 +184,7 @@ describe('WalletConnectionPopover β disconnected state', () => {
describe('WalletConnectionPopover β connected state', () => {
const FULL_ADDRESS = '0xAbCdEf1234567890AbCdEf1234567890AbCdEf12';
// shortenAddress defaults: first 6 chars + '...' + last 4 chars
- const TRUNCATED_ADDRESS = '0xAbCd...f12'.slice(0, 6) + '...' + FULL_ADDRESS.slice(-4);
+ const TRUNCATED_ADDRESS = `${FULL_ADDRESS.slice(0, 4)}...${FULL_ADDRESS.slice(-4)}`;
beforeEach(() => {
vi.clearAllMocks();
diff --git a/src/components/creator/CoCreatorSection.tsx b/src/components/creator/CoCreatorSection.tsx
new file mode 100644
index 00000000..6901bc0a
--- /dev/null
+++ b/src/components/creator/CoCreatorSection.tsx
@@ -0,0 +1,172 @@
+import { useState } from 'react';
+import { Copy, Check, UserCheck, Wallet } from 'lucide-react';
+import { truncateTxHash } from '@/constants/stellar';
+import { copyTextToClipboard } from '@/utils/clipboard.utils';
+import { bpsToPercent } from '@/utils/numberFormat.utils';
+import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils';
+import SetCoCreatorModal from './SetCoCreatorModal';
+
+interface CoCreatorSectionProps {
+ courseId: string;
+ coCreatorAddress?: string;
+ coCreatorSplitBps?: number;
+ totalPaidToCoCreator?: number;
+ totalPaidToCreator?: number;
+ className?: string;
+}
+
+export function CoCreatorSection({
+ courseId,
+ coCreatorAddress,
+ coCreatorSplitBps,
+ totalPaidToCoCreator = 0,
+ totalPaidToCreator = 0,
+ className = '',
+}: CoCreatorSectionProps) {
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const hasCoCreator = Boolean(coCreatorAddress && coCreatorSplitBps && coCreatorSplitBps > 0);
+
+ const handleCopyAddress = async () => {
+ if (!coCreatorAddress) return;
+ try {
+ await copyTextToClipboard(coCreatorAddress);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // Handle copy error silently
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Co-Creator Royalty Split
+
+
+ Revenue split arrangement for secondary royalties and key sales
+
+
+
+
+
+
+
+ {!hasCoCreator ? (
+
+
+
+ No co-creator configured
+
+
+ Configure a co-creator address and split percentage to automatically divide revenues.
+
+
+ ) : (
+
+ {/* Co-creator Config Info */}
+
+
+
+ Co-Creator Address
+
+
+
+ {truncateTxHash(coCreatorAddress ?? '', 8, 8)}
+
+
+
+
+
+
+
+ Split Percentage
+
+
+
+ {bpsToPercent(coCreatorSplitBps ?? 0)}
+
+
+
+
+
+ {/* Stat Cards */}
+
+
+
+ Total Paid to Co-Creator
+
+
+ {formatDisplayKeyPrice(totalPaidToCoCreator)}
+
+
+
+
+
+ Total Paid to Creator
+
+
+ {formatDisplayKeyPrice(totalPaidToCreator)}
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default CoCreatorSection;
diff --git a/src/components/creator/SetCoCreatorModal.tsx b/src/components/creator/SetCoCreatorModal.tsx
new file mode 100644
index 00000000..e55309a5
--- /dev/null
+++ b/src/components/creator/SetCoCreatorModal.tsx
@@ -0,0 +1,174 @@
+import React, { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { useSetCoCreator } from '@/hooks/useCreators';
+import { isValidStellarAddress, isValidBps } from '@/utils/coCreator.utils';
+
+interface SetCoCreatorModalProps {
+ courseId: string;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ initialAddress?: string;
+ initialSplitBps?: number;
+}
+
+
+
+export function SetCoCreatorModal({
+ courseId,
+ open,
+ onOpenChange,
+ initialAddress = '',
+ initialSplitBps,
+}: SetCoCreatorModalProps) {
+ const [address, setAddress] = useState(initialAddress);
+ const [splitBps, setSplitBps] = useState(
+ initialSplitBps ? String(initialSplitBps) : ''
+ );
+ const [addressError, setAddressError] = useState('');
+ const [bpsError, setBpsError] = useState('');
+
+ const setCoCreatorMutation = useSetCoCreator(courseId);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ let hasError = false;
+ setAddressError('');
+ setBpsError('');
+
+ const trimmedAddress = address.trim();
+ if (!trimmedAddress) {
+ setAddressError('Co-creator Stellar address is required');
+ hasError = true;
+ } else if (!isValidStellarAddress(trimmedAddress)) {
+ setAddressError(
+ 'Invalid Stellar address. Must start with G and be 56 characters long.'
+ );
+ hasError = true;
+ }
+
+ const parsedBps = parseInt(splitBps, 10);
+ if (!splitBps || Number.isNaN(parsedBps)) {
+ setBpsError('Split percentage (bps) is required');
+ hasError = true;
+ } else if (!isValidBps(parsedBps)) {
+ setBpsError('Split basis points must be an integer between 1 and 10000 (0.01% to 100%)');
+ hasError = true;
+ }
+
+ if (hasError) return;
+
+ try {
+ await setCoCreatorMutation.mutateAsync({
+ address: trimmedAddress,
+ splitBps: parsedBps,
+ });
+ onOpenChange(false);
+ } catch {
+ // Toast notification handled in useSetCoCreator mutation
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default SetCoCreatorModal;
diff --git a/src/components/creator/__tests__/CoCreatorSection.test.tsx b/src/components/creator/__tests__/CoCreatorSection.test.tsx
new file mode 100644
index 00000000..c41e0ece
--- /dev/null
+++ b/src/components/creator/__tests__/CoCreatorSection.test.tsx
@@ -0,0 +1,134 @@
+import { describe, expect, it, vi, beforeEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import CoCreatorSection from '../CoCreatorSection';
+import { isValidStellarAddress, isValidBps } from '@/utils/coCreator.utils';
+
+vi.mock('@/utils/toast.util', () => ({
+ default: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+vi.mock('@/services/course.service', () => ({
+ courseService: {
+ setCoCreator: vi.fn().mockResolvedValue({
+ id: 'course_123',
+ coCreatorAddress: 'GA7QW3L7Y54N4P5O3G6J8K9L0M1N2P3Q4R5S6T7U8V9W0X1Y2Z3A4B5C',
+ coCreatorSplitBps: 2500,
+ }),
+ },
+}));
+
+function makeWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return ({ children }: { children: React.ReactNode }) => (
+ {children}
+ );
+}
+
+describe('Stellar Address and BPS validation helpers', () => {
+ it('validates Stellar G-addresses correctly', () => {
+ const validAddress = 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYSFIZGK63PZZVVJAB7';
+ expect(isValidStellarAddress(validAddress)).toBe(true);
+
+ expect(isValidStellarAddress('invalid_address')).toBe(false);
+ expect(isValidStellarAddress('0x1234567890abcdef')).toBe(false);
+ expect(isValidStellarAddress('G123')).toBe(false);
+ });
+
+ it('validates basis points range correctly', () => {
+ expect(isValidBps(2500)).toBe(true);
+ expect(isValidBps(1)).toBe(true);
+ expect(isValidBps(10000)).toBe(true);
+
+ expect(isValidBps(0)).toBe(false);
+ expect(isValidBps(10001)).toBe(false);
+ expect(isValidBps(-500)).toBe(false);
+ expect(isValidBps(25.5)).toBe(false);
+ });
+});
+
+describe('CoCreatorSection Component', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('renders "No co-creator configured" empty state when no split is configured', () => {
+ render(, {
+ wrapper: makeWrapper(),
+ });
+
+ expect(screen.getByTestId('cocreator-empty-state')).toBeInTheDocument();
+ expect(screen.getByText('No co-creator configured')).toBeInTheDocument();
+ expect(screen.getByTestId('set-cocreator-button')).toHaveTextContent(
+ 'Set Co-Creator'
+ );
+ });
+
+ it('renders truncated address, split percentage, and stat cards when co-creator is set', () => {
+ const coCreatorAddress =
+ 'GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFXYSFIZGK63PZZVVJAB7';
+ render(
+ ,
+ { wrapper: makeWrapper() }
+ );
+
+ expect(screen.queryByTestId('cocreator-empty-state')).not.toBeInTheDocument();
+ expect(screen.getByTestId('cocreator-details')).toBeInTheDocument();
+
+ expect(screen.getByTestId('cocreator-split-display')).toHaveTextContent('25%');
+ expect(screen.getByTestId('total-paid-cocreator')).toHaveTextContent(/15.*XLM/);
+ expect(screen.getByTestId('total-paid-creator')).toHaveTextContent(/45.*XLM/);
+ expect(screen.getByTestId('set-cocreator-button')).toHaveTextContent(
+ 'Edit Co-Creator'
+ );
+ });
+
+ it('opens the configuration modal when Set Co-Creator is clicked', async () => {
+ const user = userEvent.setup();
+ render(, {
+ wrapper: makeWrapper(),
+ });
+
+ await user.click(screen.getByTestId('set-cocreator-button'));
+
+ expect(screen.getByTestId('set-cocreator-modal')).toBeInTheDocument();
+ expect(
+ screen.getByRole('heading', { name: /configure co-creator split/i })
+ ).toBeInTheDocument();
+ });
+
+ it('shows error messages when submitting invalid Stellar address or BPS', async () => {
+ const user = userEvent.setup();
+ render(, {
+ wrapper: makeWrapper(),
+ });
+
+ await user.click(screen.getByTestId('set-cocreator-button'));
+
+ const addressInput = screen.getByTestId('cocreator-address-input');
+ const bpsInput = screen.getByTestId('cocreator-bps-input');
+ const submitBtn = screen.getByTestId('submit-cocreator-button');
+
+ // Type invalid address and invalid BPS
+ await user.type(addressInput, 'invalid-address');
+ await user.type(bpsInput, '20000');
+ await user.click(submitBtn);
+
+ await waitFor(() => {
+ expect(screen.getByTestId('cocreator-address-error')).toBeInTheDocument();
+ expect(screen.getByTestId('cocreator-bps-error')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/hooks/__tests__/useNotifications.test.ts b/src/hooks/__tests__/useNotifications.test.ts
index 8493d3b2..077707a4 100644
--- a/src/hooks/__tests__/useNotifications.test.ts
+++ b/src/hooks/__tests__/useNotifications.test.ts
@@ -5,6 +5,15 @@ import React from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import type { NotificationsResponse } from '@/services/notification.service';
+vi.mock('@/services/notification.service', () => ({
+ notificationService: {
+ getNotifications: vi.fn(),
+ markAsRead: vi.fn().mockImplementation(() => new Promise(() => {})),
+ markAllAsRead: vi.fn().mockImplementation(() => new Promise(() => {})),
+ },
+ NotificationService: vi.fn(),
+}));
+
const USER_ID = 'user_abc123';
function makeResponse(
@@ -135,16 +144,6 @@ describe('useNotifications', () => {
},
];
- // markAsRead hits the real notificationService β stub the module-level
- // singleton so we can make it resolve without a real server.
- vi.mock('@/services/notification.service', () => ({
- notificationService: {
- getNotifications: vi.fn(),
- markAsRead: vi.fn().mockResolvedValue(undefined),
- },
- NotificationService: vi.fn(),
- }));
-
const fetchFn = vi.fn().mockResolvedValue(
makeResponse({ notifications, unreadCount: 2 })
);
diff --git a/src/hooks/useCreators.ts b/src/hooks/useCreators.ts
index 32fd514d..98343e50 100644
--- a/src/hooks/useCreators.ts
+++ b/src/hooks/useCreators.ts
@@ -1,9 +1,11 @@
-import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import {
courseService,
+ type Course,
type GetCoursesParams,
} from '@/services/course.service';
+import showToast from '@/utils/toast.util';
export function useCreatorList(params?: GetCoursesParams) {
return useQuery({
@@ -20,3 +22,30 @@ export function useCreatorDetail(id: string) {
});
}
+export function useSetCoCreator(courseId: string) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ address, splitBps }: { address: string; splitBps: number }) =>
+ courseService.setCoCreator(courseId, address, splitBps),
+ onSuccess: (updatedCourse: Course) => {
+ if (updatedCourse) {
+ queryClient.setQueryData(
+ queryKeys.creators.detail(courseId),
+ updatedCourse
+ );
+ }
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.creators.detail(courseId),
+ });
+ showToast.success('Co-creator configured successfully');
+ },
+ onError: (error: unknown) => {
+ const message =
+ error instanceof Error ? error.message : 'Failed to set co-creator';
+ showToast.error(message);
+ },
+ });
+}
+
+
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 2b1883e2..647a8a41 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -9,6 +9,8 @@ import {
const MAX_DROPDOWN_NOTIFICATIONS = 5;
export interface UseNotificationsResult {
+ /** All notifications, sorted newest first. */
+ notifications: Notification[];
/** Up to five most recent notifications for the dropdown. */
recent: Notification[];
/** Total number of unread notifications. */
@@ -17,14 +19,13 @@ export interface UseNotificationsResult {
isError: boolean;
/** Mark a single notification as read by its id. */
markAsRead: (notificationId: string) => void;
+ /** Mark all notifications as read and clear unread count. */
+ markAllAsRead: () => void;
}
/**
- * Fetches the current user's notifications and exposes a helper to mark
- * individual items as read with an optimistic update.
- *
- * The queryFn is injected so tests can supply a stub without module-level
- * patching (matches the pattern used in `useCreatorActivityFeed`).
+ * Fetches the current user's notifications, polls every 60s, and exposes helpers
+ * to mark individual or all items as read with optimistic updates.
*/
export function useNotifications(
userId: string,
@@ -40,13 +41,12 @@ export function useNotifications(
queryKey,
queryFn: () => fetchNotifications(userId),
enabled: !!userId,
+ refetchInterval: 60000,
});
const { mutate: markAsRead } = useMutation({
mutationFn: (notificationId: string) =>
notificationService.markAsRead(notificationId),
- // Optimistic update: flip the `read` flag and decrement the count
- // so the badge responds instantly without waiting for the server.
onMutate: async (notificationId: string) => {
await queryClient.cancelQueries({ queryKey });
@@ -65,7 +65,6 @@ export function useNotifications(
return { previous };
},
onError: (_err, _id, context) => {
- // Roll back the optimistic update on failure.
if (context?.previous) {
queryClient.setQueryData(
queryKey,
@@ -78,14 +77,53 @@ export function useNotifications(
},
});
- const allNotifications = data?.notifications ?? [];
- const recent = allNotifications.slice(0, MAX_DROPDOWN_NOTIFICATIONS);
+ const { mutate: markAllAsRead } = useMutation({
+ mutationFn: () => notificationService.markAllAsRead(userId),
+ onMutate: async () => {
+ await queryClient.cancelQueries({ queryKey });
+
+ const previous =
+ queryClient.getQueryData(queryKey);
+
+ if (previous) {
+ queryClient.setQueryData(queryKey, {
+ notifications: previous.notifications.map(n => ({
+ ...n,
+ read: true,
+ })),
+ unreadCount: 0,
+ });
+ }
+
+ return { previous };
+ },
+ onError: (_err, _vars, context) => {
+ if (context?.previous) {
+ queryClient.setQueryData(
+ queryKey,
+ context.previous
+ );
+ }
+ },
+ onSettled: () => {
+ void queryClient.invalidateQueries({ queryKey });
+ },
+ });
+
+ const rawNotifications = data?.notifications ?? [];
+ // Sort newest first by createdAt timestamp
+ const notifications = [...rawNotifications].sort(
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
+ );
+ const recent = notifications.slice(0, MAX_DROPDOWN_NOTIFICATIONS);
return {
+ notifications,
recent,
unreadCount: data?.unreadCount ?? 0,
isLoading,
isError,
markAsRead,
+ markAllAsRead,
};
}
diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx
index c06d638e..9a364346 100644
--- a/src/pages/CreatorDetailPage.tsx
+++ b/src/pages/CreatorDetailPage.tsx
@@ -1,4 +1,4 @@
-import { useParams } from 'react-router';
+import { Link, useParams } from 'react-router';
import { useCreatorDetail } from '@/hooks/useCreators';
import { useCreatorProfileStaleIndicator } from '@/hooks/useCreatorProfileStaleIndicator';
import CreatorBreadcrumb from '@/components/common/CreatorBreadcrumb';
@@ -15,8 +15,12 @@ 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 { useKeyHolders } from '@/hooks/useKeyHolders';
import { useNavigationTiming } from '@/hooks/useNavigationTiming';
+import { useKeyHolders } from '@/hooks/useKeyHolders';
+import { useProfileStore } from '@/hooks/useProfileStore';
+import { useWalletHoldings } from '@/hooks/useWallet';
+import CoCreatorSection from '@/components/creator/CoCreatorSection';
+import ShareTwitterButton from '@/components/common/ShareTwitterButton';
function CreatorDetailPageContent() {
const { id } = useParams<{ id: string }>();
@@ -36,6 +40,13 @@ function CreatorDetailPageContent() {
fetchNextPage,
} = useKeyHolders(id || '');
+ // User holdings for Share to X button
+ const profile = useProfileStore(state => state.profile);
+ const userAddress = profile?.id;
+ const { data: holdings = [] } = useWalletHoldings(userAddress ?? '');
+ const userPosition = holdings.find(h => h.creatorId === (id || ''));
+ const holdingsCount = userPosition?.quantity ?? 0;
+
// Track stale data indicator
const { shouldShowBadge, handleRefetch } = useCreatorProfileStaleIndicator(
id || '',
@@ -53,14 +64,23 @@ function CreatorDetailPageContent() {
);
}
- if (error) {
+ if (error || !creator) {
+ const is404 =
+ !creator || (error instanceof ApiError && error.status === 404);
+ if (is404) {
+ return (
+
+ Creator not found
+ We couldn't find a creator with that ID.
+
+ Back to creators
+
+
+ );
+ }
throw error;
}
- if (!creator) {
- throw new ApiError('Creator not found', 404);
- }
-
const feeItems = [
{
label: 'Creator fee',
@@ -152,6 +172,19 @@ function CreatorDetailPageContent() {
+ {/* Share to X Button (only visible for authenticated holders) */}
+
+
+
+
{/* Staking Rewards */}
+ {/* Co-Creator Section */}
+