From 8d7e1c9edf2c2a676cb36224ce1db5de1eb4cd11 Mon Sep 17 00:00:00 2001 From: Bathoul Mohammed Date: Thu, 30 Jul 2026 00:25:37 +0100 Subject: [PATCH] feat: add share button to copy creator profile URL (#724) Copy the profile URL via the Clipboard API, show Copied! for 2s, fall back to window.prompt, and omit the control when window is undefined. Co-authored-by: Cursor --- .../common/CreatorProfileHeader.tsx | 164 +++++++++--------- .../__tests__/CreatorProfileHeader.test.tsx | 118 +++++++++++-- 2 files changed, 189 insertions(+), 93 deletions(-) diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 8e2fc160..c3acff48 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -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'; @@ -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 = ({ name, handle, @@ -43,6 +43,7 @@ const CreatorProfileHeader: React.FC = ({ }) => { const [copied, setCopied] = useState(false); const [isScrolled, setIsScrolled] = useState(false); + const copiedTimeoutRef = useRef | null>(null); const { format } = useFormatXlm(); useEffect(() => { @@ -53,55 +54,57 @@ const CreatorProfileHeader: React.FC = ({ 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 (
-
- {own && ( - <> +
+ {own && ( + <> + + + + )} + {canShowShareButton && ( - - - )} - + )}
diff --git a/src/components/common/__tests__/CreatorProfileHeader.test.tsx b/src/components/common/__tests__/CreatorProfileHeader.test.tsx index 64e3d867..9a0cc99e 100644 --- a/src/components/common/__tests__/CreatorProfileHeader.test.tsx +++ b/src/components/common/__tests__/CreatorProfileHeader.test.tsx @@ -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( { /> ); - // 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( + + ); + + 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( + + ); + + 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( + + ); + + 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); }); });