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
164 changes: 81 additions & 83 deletions src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import { Copy, Check, Share2, Pencil } from 'lucide-react';
import showToast from '@/utils/toast.util';
import { Share2, Pencil } from 'lucide-react';
import appendUtmParams from '@/utils/utm.utils';
import { copyTextToClipboard } from '@/utils/clipboard.utils';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import VerifiedBadge from '@/components/common/VerifiedBadge';
Expand All @@ -30,6 +28,8 @@ interface CreatorProfileHeaderProps {
const CREATOR_PROFILE_SUBTITLE_WRAP_CLASS_NAME =
'max-w-full whitespace-normal break-words [overflow-wrap:anywhere]';

const COPIED_FEEDBACK_MS = 2000;

const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
name,
handle,
Expand All @@ -43,6 +43,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
}) => {
const [copied, setCopied] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { format } = useFormatXlm();

useEffect(() => {
Expand All @@ -53,55 +54,57 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
return () => window.removeEventListener('scroll', handleScroll);
}, []);

useEffect(() => {
return () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
}
};
}, []);

// Display-normalised handle; raw `handle` is preserved for any equality /
// URL construction the caller might do via the prop.
const displayHandle = formatCreatorHandle(handle);
const displayName = normalizeCreatorDisplayName(name) || 'Unnamed creator';
const normalizedCreatorId =
creatorId == null ? creatorId : String(creatorId);
creatorId == null ? creatorId : String(creatorId);

const own = isOwnWallet(connectedWalletAddress, normalizedCreatorId);
const own = isOwnWallet(connectedWalletAddress, normalizedCreatorId);

const handleShare = async () => {
let url = window.location.href;

// Append UTM params when configured (no-op if none configured)
url = appendUtmParams(url);
const url = appendUtmParams(window.location.href);
const canUseClipboard =
typeof navigator !== 'undefined' &&
typeof navigator.clipboard?.writeText === 'function';

if (navigator.share) {
if (canUseClipboard) {
try {
await navigator.share({
title: `${displayName} (${displayHandle || `@${handle}`}) on Access Layer`,
url,
});
} catch (err) {
// User cancelled the share dialog — not an error worth surfacing
if (err instanceof Error && err.name !== 'AbortError') {
showToast.error('Failed to share profile');
await navigator.clipboard.writeText(url);
setCopied(true);
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
}
copiedTimeoutRef.current = setTimeout(() => {
setCopied(false);
copiedTimeoutRef.current = null;
}, COPIED_FEEDBACK_MS);
return;
} catch {
// Fall through to the prompt fallback below.
}
return;
}

// Fallback: copy to clipboard
try {
await copyTextToClipboard(url);
setCopied(true);
showToast.success('Profile link copied to clipboard!');
setTimeout(() => setCopied(false), 2000);
} catch {
showToast.error(
'Could not copy the profile link. Please copy it manually.'
);
}
window.prompt(url);
};

const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
const displayPrice =
priceStroops != null && Number.isFinite(priceStroops)
? `${format(priceStroops)} XLM`
: null;

// Issue #724: omit the share control during SSR (`window` undefined).
const canShowShareButton = typeof window !== 'undefined';

return (
<div
className={cn(
Expand Down Expand Up @@ -188,66 +191,61 @@ const own = isOwnWallet(connectedWalletAddress, normalizedCreatorId);
</div>
</div>

<div
className={cn(
'flex items-center gap-3 transition-transform duration-300',
isScrolled ? 'scale-90' : 'scale-100'
)}
>
{own && (
<>
<div
className={cn(
'flex items-center gap-3 transition-transform duration-300',
isScrolled ? 'scale-90' : 'scale-100'
)}
>
{own && (
<>
<Button
aria-label="Edit bio"
variant="outline"
className={cn(
'rounded-xl border-white/10 bg-white/5 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10 active:scale-95',
isScrolled ? 'h-9 px-3 text-xs' : 'h-11 px-4 text-sm'
)}
>
<Pencil className="mr-2 size-4 text-amber-500" />
<span className="hidden sm:inline">Edit Bio</span>
<span className="sm:hidden">Edit</span>
</Button>
<Button
aria-label="Change avatar"
variant="outline"
className={cn(
'rounded-xl border-white/10 bg-white/5 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10 active:scale-95',
isScrolled ? 'h-9 px-3 text-xs' : 'h-11 px-4 text-sm'
)}
>
<Pencil className="mr-2 size-4 text-amber-500" />
<span className="hidden sm:inline">Change Avatar</span>
<span className="sm:hidden">Avatar</span>
</Button>
</>
)}
{canShowShareButton && (
<Button
aria-label="Edit bio"
type="button"
onClick={handleShare}
aria-label={copied ? 'Copied!' : 'Share profile'}
variant="outline"
className={cn(
'rounded-xl border-white/10 bg-white/5 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10 active:scale-95',
isScrolled ? 'h-9 px-3 text-xs' : 'h-11 px-4 text-sm'
)}
>
<Pencil className="mr-2 size-4 text-amber-500" />
<span className="hidden sm:inline">Edit Bio</span>
<span className="sm:hidden">Edit</span>
</Button>
<Button
aria-label="Change avatar"
variant="outline"
className={cn(
'rounded-xl border-white/10 bg-white/5 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10 active:scale-95',
isScrolled ? 'h-9 px-3 text-xs' : 'h-11 px-4 text-sm'
{copied ? (
<span>Copied!</span>
) : (
<Share2
className="size-4 text-amber-500"
aria-hidden="true"
/>
)}
>
<Pencil className="mr-2 size-4 text-amber-500" />
<span className="hidden sm:inline">Change Avatar</span>
<span className="sm:hidden">Avatar</span>
</Button>
</>
)}
<Button
onClick={handleShare}
variant="outline"
className={cn(
'rounded-xl border-white/10 bg-white/5 font-bold text-white transition-all hover:border-amber-500/30 hover:bg-amber-500/10 active:scale-95',
isScrolled ? 'h-9 px-3 text-xs' : 'h-11 px-4 text-sm'
)}
>
{copied ? (
<Check className="mr-2 size-4 text-emerald-400" />
) : canNativeShare ? (
<Share2 className="mr-2 size-4 text-amber-500" />
) : (
<Copy className="mr-2 size-4 text-amber-500" />
)}
<span className="hidden sm:inline">
{copied
? 'Copied!'
: canNativeShare
? 'Share Profile'
: 'Copy Profile Link'}
</span>
<span className="sm:hidden">
{copied ? 'Copied' : canNativeShare ? 'Share' : 'Copy'}
</span>
</Button>
)}
</div>
</div>
</div>
Expand Down
118 changes: 108 additions & 10 deletions src/components/common/__tests__/CreatorProfileHeader.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import CreatorProfileHeader from '@/components/common/CreatorProfileHeader';

const PROFILE_URL = 'https://accesslayer.example/creators/arivers';

describe('CreatorProfileHeader', () => {
it('renders a share/copy button for profile actions', async () => {
beforeEach(() => {
vi.stubGlobal('location', {
...window.location,
href: PROFILE_URL,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
vi.stubGlobal('prompt', vi.fn());
});

afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('renders a share button that copies the profile URL to the clipboard', async () => {
render(
<CreatorProfileHeader
name="Alex Rivers"
Expand All @@ -14,14 +36,90 @@ describe('CreatorProfileHeader', () => {
/>
);

// The header exposes a share/copy button for profile link actions
const actionButton = screen.getByRole('button', {
name: /Copy Profile Link|Copy|Share Profile|Share/i,
const shareButton = screen.getByRole('button', { name: /share profile/i });
expect(shareButton).toBeInTheDocument();

await act(async () => {
fireEvent.click(shareButton);
});

expect(navigator.clipboard.writeText).toHaveBeenCalledWith(PROFILE_URL);
expect(screen.getByRole('button', { name: /copied!/i })).toBeInTheDocument();
expect(screen.getByText('Copied!')).toBeInTheDocument();
expect(window.prompt).not.toHaveBeenCalled();
});

it('reverts from Copied! back to the share icon after 2 seconds', async () => {
vi.useFakeTimers();

render(
<CreatorProfileHeader
name="Alex Rivers"
handle="arivers"
creatorId="arivers"
/>
);

const shareButton = screen.getByRole('button', { name: /share profile/i });

await act(async () => {
fireEvent.click(shareButton);
});

expect(screen.getByText('Copied!')).toBeInTheDocument();

await act(async () => {
vi.advanceTimersByTime(2000);
});

expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
expect(
screen.getByRole('button', { name: /share profile/i })
).toBeInTheDocument();
});

it('falls back to window.prompt when the Clipboard API is unavailable', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
});

render(
<CreatorProfileHeader
name="Alex Rivers"
handle="arivers"
creatorId="arivers"
/>
);

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /share profile/i }));
});

expect(window.prompt).toHaveBeenCalledWith(PROFILE_URL);
expect(screen.queryByText('Copied!')).not.toBeInTheDocument();
});

it('falls back to window.prompt when clipboard writeText rejects', async () => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockRejectedValue(new Error('denied')),
},
});

render(
<CreatorProfileHeader
name="Alex Rivers"
handle="arivers"
creatorId="arivers"
/>
);

await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /share profile/i }));
});

expect(actionButton).toBeInTheDocument();
fireEvent.click(actionButton);
// clicking should not throw and button remains in the document
expect(actionButton).toBeInTheDocument();
expect(window.prompt).toHaveBeenCalledWith(PROFILE_URL);
});
});
Loading