From 870bce04663b148447465e08d60ce8d8eeaea4c0 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Sun, 28 Jun 2026 07:48:49 +0530 Subject: [PATCH 1/2] feat(ui): add reusable copy-to-clipboard component with success animation Introduce shared clipboard utility, hook, and CopyToClipboardButton with checkmark feedback and a 2.5s Copied state. Migrate existing copy actions across profile sharing, settings, goals, streaks, and career tools. --- src/app/dashboard/RoastHypeWidget.tsx | 37 +++-- src/app/dashboard/settings/page.tsx | 47 ++----- src/components/BadgeSection.tsx | 29 ++-- src/components/CopyLinkButton.tsx | 60 ++------- src/components/CopyToClipboardButton.tsx | 87 ++++++++++++ src/components/GoalTracker.tsx | 48 +++---- src/components/ShareProfileButton.tsx | 52 ++----- src/components/StreakTracker.tsx | 61 ++++----- src/components/WeeklySummaryCard.tsx | 42 ++---- .../career-intelligence/ExportPanel.tsx | 80 +++++------ .../career-intelligence/ResumePreview.tsx | 127 ++++++++---------- src/hooks/useCopyToClipboard.ts | 94 +++++++++++++ src/lib/copy-to-clipboard.ts | 39 ++++++ test/copy-to-clipboard.test.ts | 112 +++++++++++++++ 14 files changed, 541 insertions(+), 374 deletions(-) create mode 100644 src/components/CopyToClipboardButton.tsx create mode 100644 src/hooks/useCopyToClipboard.ts create mode 100644 src/lib/copy-to-clipboard.ts create mode 100644 test/copy-to-clipboard.test.ts diff --git a/src/app/dashboard/RoastHypeWidget.tsx b/src/app/dashboard/RoastHypeWidget.tsx index e9178be6d..d696a03ab 100644 --- a/src/app/dashboard/RoastHypeWidget.tsx +++ b/src/app/dashboard/RoastHypeWidget.tsx @@ -1,7 +1,8 @@ "use client"; import { useState } from 'react'; -import { Copy, Sparkles, Flame } from 'lucide-react'; +import { Sparkles, Flame } from 'lucide-react'; +import CopyToClipboardButton from '@/components/CopyToClipboardButton'; interface UserStats { commits: number; @@ -14,12 +15,14 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) { const [mode, setMode] = useState<'roast' | 'hype'>('hype'); const [loading, setLoading] = useState(false); const [output, setOutput] = useState(null); - const [copied, setCopied] = useState(false); + + const shareText = output + ? `DevTrack ${mode === 'hype' ? 'Hype' : 'Roast'}:\n"${output}"\n\nTrack your stats at devtrack.app! 🚀` + : ""; const generateContent = async () => { setLoading(true); setOutput(null); - setCopied(false); try { const response = await fetch('/api/ai/roast', { @@ -42,14 +45,6 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) { } }; - const copyToClipboard = () => { - if (output) { - navigator.clipboard.writeText(`DevTrack ${mode === 'hype' ? 'Hype' : 'Roast'}:\n"${output}"\n\nTrack your stats at devtrack.app! 🚀`); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } - }; - return (
@@ -93,17 +88,15 @@ export default function RoastHypeWidget({ stats }: { stats: UserStats }) { {output && (

“{output}”

- +
)}
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 41cba8a5c..b95d91b51 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -15,6 +15,8 @@ import { useRouter } from "next/navigation"; import WebhookManager from "@/components/webhook/WebhookManager"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; import { useLocale, useTranslations } from "next-intl"; import { localeMetadata, locales, type AppLocale } from "@/i18n/config"; @@ -178,7 +180,6 @@ function SettingsPageContent() { const [saving, setSaving] = useState(false); const [webhookUrl, setWebhookUrl] = useState(null); const [webhookSaving, setWebhookSaving] = useState(false); - const [copied, setCopied] = useState(false); const [removeError, setRemoveError] = useState(null); const [accountsError, setAccountsError] = useState(null); const [removingAccountId, setRemovingAccountId] = useState( @@ -197,7 +198,6 @@ function SettingsPageContent() { const [testingDiscord, setTestingDiscord] = useState(false); const [discordMutedUntil, setDiscordMutedUntil] = useState(null); const [muteDuration, setMuteDuration] = useState(1); - const copyResetTimerRef = useRef(null); // GitHub Orgs States const [orgAccounts, setOrgAccounts] = useState([]); @@ -789,32 +789,6 @@ function SettingsPageContent() { } }; - const copyShareLink = () => { - if (!profileUrl) return; - - if (copyResetTimerRef.current) { - window.clearTimeout(copyResetTimerRef.current); - copyResetTimerRef.current = null; - } - - if (!navigator.clipboard?.writeText) { - toast.error("Clipboard access is not available in this browser."); - return; - } - - navigator.clipboard.writeText(profileUrl).then(() => { - setCopied(true); - toast.success("Link copied successfully!"); - copyResetTimerRef.current = window.setTimeout(() => { - setCopied(false); - copyResetTimerRef.current = null; - }, 2000); - }).catch((err) => { - console.error("Clipboard copy failed:", err); - toast.error("Failed to copy profile URL"); - }); - }; - const handleRemoveAccount = async (githubId: string) => { setRemoveError(null); setRemovingAccountId(githubId); @@ -969,15 +943,18 @@ function SettingsPageContent() { aria-label="Public profile URL" className="min-w-0 flex-1 rounded-xl border border-[var(--border)] bg-[var(--control)] px-4 py-2 text-sm text-[var(--card-foreground)] outline-none transition-colors focus:border-[var(--accent)]" /> - + showToast + successMessage="Link copied successfully!" + errorMessage="Failed to copy profile URL" + ariaLabel="Copy public profile URL" + disabled={!profileUrl} + />
{!settings.is_public && (

diff --git a/src/components/BadgeSection.tsx b/src/components/BadgeSection.tsx index 5eaea5bb4..885249c17 100644 --- a/src/components/BadgeSection.tsx +++ b/src/components/BadgeSection.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import Image from "next/image"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; interface BadgeSectionProps { username: string; @@ -137,30 +138,20 @@ function Toast({ visible }: { visible: boolean }) { * Copyable code block component */ function CopyableCodeBlock({ code, onCopySuccess }: { code: string; onCopySuccess?: () => void }) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(code); - setCopied(true); - onCopySuccess?.(); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; - return (

{code} - +
); } diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx index bf92a0e75..70ddbd931 100644 --- a/src/components/CopyLinkButton.tsx +++ b/src/components/CopyLinkButton.tsx @@ -1,56 +1,24 @@ "use client"; -import { useState } from "react"; -import { toast } from "sonner"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; interface CopyLinkButtonProps { url: string; } export default function CopyLinkButton({ url }: CopyLinkButtonProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - // Fallback strategy for older browsers - if (!navigator.clipboard) { - const textArea = document.createElement("textarea"); - textArea.value = url; - document.body.appendChild(textArea); - textArea.select(); - try { - document.execCommand("copy"); - triggerSuccess(); - } catch (err) { - toast.error("Failed to copy link."); - } - document.body.removeChild(textArea); - return; - } - - // Modern browser copying execution - try { - await navigator.clipboard.writeText(url); - triggerSuccess(); - } catch (err) { - toast.error("Failed to copy link."); - } - }; - - const triggerSuccess = () => { - setCopied(true); - toast.success("Link copied!", { duration: 2000 }); - setTimeout(() => setCopied(false), 2000); - }; - return ( - + ); -} \ No newline at end of file +} diff --git a/src/components/CopyToClipboardButton.tsx b/src/components/CopyToClipboardButton.tsx new file mode 100644 index 000000000..32d3acb9d --- /dev/null +++ b/src/components/CopyToClipboardButton.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Check, Copy } from "lucide-react"; +import { Button, type ButtonProps } from "@/components/ui/button"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { cn } from "@/lib/utils"; + +export interface CopyToClipboardButtonProps + extends Omit { + value: string; + label?: string; + copiedLabel?: string; + iconOnly?: boolean; + showToast?: boolean; + resetDelay?: number; + successMessage?: string; + errorMessage?: string; + ariaLabel?: string; + onCopied?: () => void; +} + +export default function CopyToClipboardButton({ + value, + label = "Copy", + copiedLabel = "Copied!", + iconOnly = false, + showToast = false, + resetDelay = 2500, + successMessage, + errorMessage, + ariaLabel, + onCopied, + className, + variant = "outline", + size, + disabled, + ...buttonProps +}: CopyToClipboardButtonProps) { + const { copy, copied } = useCopyToClipboard({ + resetDelay, + showToast, + successMessage, + errorMessage, + onSuccess: onCopied, + }); + + const resolvedSize = size ?? (iconOnly ? "icon" : "default"); + const defaultAriaLabel = ariaLabel ?? label; + + return ( + + ); +} diff --git a/src/components/GoalTracker.tsx b/src/components/GoalTracker.tsx index 41c6b0fa4..1e6063e70 100644 --- a/src/components/GoalTracker.tsx +++ b/src/components/GoalTracker.tsx @@ -8,6 +8,7 @@ import ConfirmModal from "@/components/ConfirmModal"; import { buildPublicGoalShareUrl } from "@/lib/goals/share"; import GoalHistory from "@/components/GoalHistory"; import EmptyState from "@/components/EmptyState"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; type Recurrence = "none" | "weekly" | "monthly"; @@ -368,7 +369,6 @@ export default function GoalTracker() { ? (session as { githubLogin: string }).githubLogin : null; - const [copiedGoalId, setCopiedGoalId] = useState(null); const [sharingGoalId, setSharingGoalId] = useState(null); const [shareError, setShareError] = useState(null); @@ -400,31 +400,6 @@ export default function GoalTracker() { } }; - const copyGoalShareLink = async (goalId: string) => { - if (!githubLogin) { - setShareError("Unable to build share link for this account."); - return; - } - - const shareUrl = buildPublicGoalShareUrl( - window.location.origin, - githubLogin, - goalId - ); - - try { - await navigator.clipboard.writeText(shareUrl); - setCopiedGoalId(goalId); - window.setTimeout(() => { - setCopiedGoalId((currentGoalId) => - currentGoalId === goalId ? null : currentGoalId - ); - }, 2000); - } catch { - setShareError("Failed to copy share link. Please copy it manually."); - } - }; - const activeConfirmingGoal = goals.find((g) => g.id === confirmingId); if (loading) { @@ -734,14 +709,21 @@ export default function GoalTracker() { - {goal.is_public && ( - + ariaLabel={`Copy share link for ${goal.title}`} + errorMessage="Failed to copy share link. Please copy it manually." + /> )} diff --git a/src/components/ShareProfileButton.tsx b/src/components/ShareProfileButton.tsx index 9f6e581a1..111199044 100644 --- a/src/components/ShareProfileButton.tsx +++ b/src/components/ShareProfileButton.tsx @@ -1,9 +1,6 @@ "use client"; -import { useState } from "react"; -import { Link2, Check } from "lucide-react"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; interface ShareProfileButtonProps { githubLogin: string; @@ -12,42 +9,19 @@ interface ShareProfileButtonProps { export default function ShareProfileButton({ githubLogin, }: ShareProfileButtonProps) { - const [copied, setCopied] = useState(false); - - const handleCopy = async () => { - try { - const baseUrl = - process.env.NEXT_PUBLIC_APP_URL || - "https://devtrack-delta.vercel.app"; - - const profileUrl = `${baseUrl}/u/${githubLogin}`; - - await navigator.clipboard.writeText(profileUrl); - - setCopied(true); - toast.success("Link copied!"); - - setTimeout(() => { - setCopied(false); - }, 2000); - } catch { - toast.error("Failed to copy link"); - } - }; + const baseUrl = + process.env.NEXT_PUBLIC_APP_URL || "https://devtrack-delta.vercel.app"; + const profileUrl = `${baseUrl}/u/${githubLogin}`; return ( - + ); } diff --git a/src/components/StreakTracker.tsx b/src/components/StreakTracker.tsx index 3b0d5e0fd..bb2cebdb7 100644 --- a/src/components/StreakTracker.tsx +++ b/src/components/StreakTracker.tsx @@ -8,9 +8,11 @@ import { useCountUp } from "@/hooks/useCountUp"; import StreakMilestoneBanner from "@/components/StreakMilestoneBanner"; import { useHeatmapTheme } from "@/hooks/useHeatmapTheme"; import { toast } from "sonner"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { toPng } from "html-to-image"; -import { Flame, Trophy, Calendar, Zap, Copy, CheckCircle, Medal, Star, Sparkles } from "lucide-react"; +import { Flame, Trophy, Calendar, Zap, CheckCircle, Medal, Star, Sparkles } from "lucide-react"; import ConfirmModal from "@/components/ConfirmModal"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; const DATA_WINDOW_DAYS = 90; const dataWindowLabel = `Last ${DATA_WINDOW_DAYS} days`; @@ -47,7 +49,11 @@ export function useStreakTracker() { const [lastCelebratedMilestone, setLastCelebratedMilestone] = useState(0); const [lastUpdated, setLastUpdated] = useState(null); const [minutesAgo, setMinutesAgo] = useState(0); - const [copied, setCopied] = useState(false); + const { copy, copied } = useCopyToClipboard({ + showToast: true, + successMessage: "Streak stats copied to clipboard!", + errorMessage: "Failed to copy streak stats.", + }); const [error, setError] = useState(null); const [calendarMonth, setCalendarMonth] = useState(new Date()); const [freeze, setFreeze] = useState(null); @@ -294,23 +300,7 @@ export function useStreakTracker() { `Active days: ${data.totalActiveDays}`, ].join("\n"); - if (typeof navigator === "undefined" || !navigator.clipboard) { - toast.error("Clipboard is not supported in this browser."); - return; - } - - try { - await navigator.clipboard.writeText(textToCopy); - - setCopied(true); - - toast.success("Streak stats copied to clipboard!"); - - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy streak stats:", err); - toast.error("Failed to copy streak stats."); - } + await copy(textToCopy); }; // ------------------------------------------------------------------------- @@ -349,7 +339,6 @@ export function useStreakTracker() { lastUpdated, minutesAgo, copied, - setCopied, error, setError, calendarMonth, @@ -397,8 +386,6 @@ export default function StreakTracker() { lastCelebratedMilestone, lastUpdated, minutesAgo, - copied, - setCopied, error, setError, calendarMonth, @@ -424,7 +411,6 @@ export default function StreakTracker() { currentMilestone, shouldShowBanner, handleDismissBanner, - handleCopy, } = useStreakTracker(); const { setSummary, setIsUpdating } = useDashboardWidgetA11y("streak-tracker"); @@ -577,18 +563,23 @@ export default function StreakTracker() {
{data && (
- + +

{ai.text} diff --git a/src/components/career-intelligence/ExportPanel.tsx b/src/components/career-intelligence/ExportPanel.tsx index cd31d4bf7..f254007d4 100644 --- a/src/components/career-intelligence/ExportPanel.tsx +++ b/src/components/career-intelligence/ExportPanel.tsx @@ -1,8 +1,9 @@ "use client"; -import React, { useState } from "react"; -import { FileText, FileCode, Braces, Copy, Check, Download } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { FileText, FileCode, Braces, Download } from "lucide-react"; import { cn } from "@/lib/utils"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; import type { ResumeContent, ExportFormat } from "@/types/cv-types"; interface ExportPanelProps { @@ -12,30 +13,20 @@ interface ExportPanelProps { export default function ExportPanel({ content, onExport }: ExportPanelProps) { const [exportingFormat, setExportingFormat] = useState(null); - const [copied, setCopied] = useState(false); - const handleExport = async (format: ExportFormat) => { - setExportingFormat(format); - try { - await onExport(format); - } catch (err) { - console.error(err); - } finally { - setExportingFormat(null); - } - }; - - const copyToClipboard = async () => { - try { - const bulletText = content.bulletPoints.map((bp) => `- ${bp.text}`).join("\n"); - const projectText = content.projectDescriptions - .map((p) => `### ${p.name}\n${p.description}\n${p.highlights.map((h) => `- ${h}`).join("\n")}`) - .join("\n\n"); - const skillText = content.skills - .map((c) => `**${c.category}**: ${c.skills.join(", ")}`) - .join("\n"); - - const textToCopy = ` + const resumeText = useMemo(() => { + const bulletText = content.bulletPoints.map((bp) => `- ${bp.text}`).join("\n"); + const projectText = content.projectDescriptions + .map( + (p) => + `### ${p.name}\n${p.description}\n${p.highlights.map((h) => `- ${h}`).join("\n")}`, + ) + .join("\n\n"); + const skillText = content.skills + .map((c) => `**${c.category}**: ${c.skills.join(", ")}`) + .join("\n"); + + return ` # Resume: ${content.role} ## Professional Summary @@ -50,13 +41,17 @@ ${projectText} ## Skills Summary ${content.skillSummary} ${skillText} - `.trim(); + `.trim(); + }, [content]); - await navigator.clipboard.writeText(textToCopy); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + const handleExport = async (format: ExportFormat) => { + setExportingFormat(format); + try { + await onExport(format); } catch (err) { - console.error("Failed to copy resume content:", err); + console.error(err); + } finally { + setExportingFormat(null); } }; @@ -138,23 +133,14 @@ ${skillText}

- +
); diff --git a/src/components/career-intelligence/ResumePreview.tsx b/src/components/career-intelligence/ResumePreview.tsx index dabbc8fcf..09c1d7445 100644 --- a/src/components/career-intelligence/ResumePreview.tsx +++ b/src/components/career-intelligence/ResumePreview.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { Edit2, Eye, Copy, Check, Info, Trash2, Plus } from "lucide-react"; +import { Edit2, Eye, Info, Trash2, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; +import CopyToClipboardButton from "@/components/CopyToClipboardButton"; import type { ResumeContent, ResumeBulletPoint, ProjectDescription, SkillCategory } from "@/types/cv-types"; interface ResumePreviewProps { @@ -14,17 +15,30 @@ interface ResumePreviewProps { export default function ResumePreview({ content, onContentChange }: ResumePreviewProps) { const [isEditMode, setIsEditMode] = useState(false); - const [copiedSection, setCopiedSection] = useState(null); - - const handleCopySection = async (sectionName: string, text: string) => { - try { - await navigator.clipboard.writeText(text); - setCopiedSection(sectionName); - setTimeout(() => setCopiedSection(null), 2000); - } catch (err) { - console.error("Failed to copy section:", err); - } - }; + + const experienceText = useMemo( + () => content.bulletPoints.map((bp) => `- ${bp.text}`).join("\n"), + [content.bulletPoints], + ); + + const projectsText = useMemo( + () => + content.projectDescriptions + .map( + (p) => + `### ${p.name}\n${p.description}\n${p.highlights.map((h) => `- ${h}`).join("\n")}`, + ) + .join("\n\n"), + [content.projectDescriptions], + ); + + const skillsText = useMemo( + () => + `${content.skillSummary}\n\n${content.skills + .map((c) => `${c.category}: ${c.skills.join(", ")}`) + .join("\n")}`, + [content.skillSummary, content.skills], + ); // State update helpers const updateSummary = (newSummary: string) => { @@ -191,14 +205,14 @@ export default function ResumePreview({ content, onContentChange }: ResumePrevie

Professional Summary

- +
{isEditMode ? ( @@ -220,19 +234,14 @@ export default function ResumePreview({ content, onContentChange }: ResumePrevie

Experience Bullet Points

- +
@@ -319,24 +328,14 @@ export default function ResumePreview({ content, onContentChange }: ResumePrevie

Project Highlights

- +
@@ -442,22 +441,14 @@ export default function ResumePreview({ content, onContentChange }: ResumePrevie

Technical Skills & Categories

- +
diff --git a/src/hooks/useCopyToClipboard.ts b/src/hooks/useCopyToClipboard.ts new file mode 100644 index 000000000..768bc3d31 --- /dev/null +++ b/src/hooks/useCopyToClipboard.ts @@ -0,0 +1,94 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { copyTextToClipboard } from "@/lib/copy-to-clipboard"; + +export interface UseCopyToClipboardOptions { + resetDelay?: number; + showToast?: boolean; + successMessage?: string; + errorMessage?: string; + onSuccess?: () => void; +} + +export function useCopyToClipboard({ + resetDelay = 2500, + showToast = false, + successMessage = "Copied to clipboard!", + errorMessage = "Failed to copy to clipboard.", + onSuccess, +}: UseCopyToClipboardOptions = {}) { + const [copied, setCopied] = useState(false); + const [copiedId, setCopiedId] = useState(null); + const resetTimerRef = useRef(null); + + const clearResetTimer = useCallback(() => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + resetTimerRef.current = null; + } + }, []); + + useEffect(() => clearResetTimer, [clearResetTimer]); + + const resetCopiedState = useCallback(() => { + setCopied(false); + setCopiedId(null); + }, []); + + const copy = useCallback( + async (text: string, id?: string) => { + clearResetTimer(); + + try { + await copyTextToClipboard(text); + setCopied(true); + setCopiedId(id ?? null); + + if (showToast) { + toast.success(successMessage, { duration: resetDelay }); + } + + onSuccess?.(); + + resetTimerRef.current = window.setTimeout(() => { + resetCopiedState(); + resetTimerRef.current = null; + }, resetDelay); + + return true; + } catch { + toast.error(errorMessage); + resetCopiedState(); + return false; + } + }, + [ + clearResetTimer, + errorMessage, + resetCopiedState, + resetDelay, + showToast, + successMessage, + onSuccess, + ], + ); + + const isCopied = useCallback( + (id?: string) => { + if (!copied) return false; + if (id === undefined) return true; + return copiedId === id; + }, + [copied, copiedId], + ); + + return { + copy, + copied, + copiedId, + isCopied, + resetCopiedState, + }; +} diff --git a/src/lib/copy-to-clipboard.ts b/src/lib/copy-to-clipboard.ts new file mode 100644 index 000000000..a44cb79ae --- /dev/null +++ b/src/lib/copy-to-clipboard.ts @@ -0,0 +1,39 @@ +export class CopyToClipboardError extends Error { + constructor(message = "Failed to copy to clipboard.") { + super(message); + this.name = "CopyToClipboardError"; + } +} + +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall through to legacy copy when clipboard API is blocked. + } + } + + if (typeof document === "undefined") { + throw new CopyToClipboardError(); + } + + const textArea = document.createElement("textarea"); + textArea.value = text; + textArea.setAttribute("readonly", ""); + textArea.style.position = "fixed"; + textArea.style.opacity = "0"; + textArea.style.pointerEvents = "none"; + document.body.appendChild(textArea); + textArea.select(); + + try { + const copied = document.execCommand("copy"); + if (!copied) { + throw new CopyToClipboardError(); + } + } finally { + document.body.removeChild(textArea); + } +} diff --git a/test/copy-to-clipboard.test.ts b/test/copy-to-clipboard.test.ts new file mode 100644 index 000000000..dcd8baf6a --- /dev/null +++ b/test/copy-to-clipboard.test.ts @@ -0,0 +1,112 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { + CopyToClipboardError, + copyTextToClipboard, +} from "@/lib/copy-to-clipboard"; + +describe("copyTextToClipboard", () => { + beforeEach(() => { + Object.defineProperty(window.navigator, "clipboard", { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("writes text with the clipboard API", async () => { + const writeText = vi.spyOn(window.navigator.clipboard, "writeText"); + + await copyTextToClipboard("hello world"); + + expect(writeText).toHaveBeenCalledWith("hello world"); + }); + + it("falls back to execCommand when clipboard API fails", async () => { + vi.spyOn(window.navigator.clipboard, "writeText").mockRejectedValue( + new Error("blocked"), + ); + + const execCommand = vi.fn().mockReturnValue(true); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + await copyTextToClipboard("fallback text"); + + expect(execCommand).toHaveBeenCalledWith("copy"); + }); + + it("throws CopyToClipboardError when fallback copy fails", async () => { + vi.spyOn(window.navigator.clipboard, "writeText").mockRejectedValue( + new Error("blocked"), + ); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: vi.fn().mockReturnValue(false), + }); + + await expect(copyTextToClipboard("fail")).rejects.toBeInstanceOf( + CopyToClipboardError, + ); + }); +}); + +describe("useCopyToClipboard", () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(window.navigator, "clipboard", { + value: { + writeText: vi.fn().mockResolvedValue(undefined), + }, + configurable: true, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("marks copied true after a successful copy", async () => { + const { result } = renderHook(() => useCopyToClipboard({ resetDelay: 2500 })); + + await act(async () => { + await result.current.copy("profile link"); + }); + + expect(result.current.copied).toBe(true); + }); + + it("resets copied state after resetDelay", async () => { + const { result } = renderHook(() => useCopyToClipboard({ resetDelay: 2500 })); + + await act(async () => { + await result.current.copy("profile link"); + }); + + act(() => { + vi.advanceTimersByTime(2500); + }); + + expect(result.current.copied).toBe(false); + }); + + it("tracks copiedId when an id is provided", async () => { + const { result } = renderHook(() => useCopyToClipboard()); + + await act(async () => { + await result.current.copy("goal link", "goal-1"); + }); + + expect(result.current.isCopied("goal-1")).toBe(true); + expect(result.current.isCopied("goal-2")).toBe(false); + }); +}); From 28ae51d63409255d206906deba00d6cb93fac891 Mon Sep 17 00:00:00 2001 From: Prerak Yadav Date: Sun, 28 Jun 2026 08:02:28 +0530 Subject: [PATCH 2/2] fix(settings): remove duplicate CopyToClipboardButton import --- src/app/dashboard/settings/page.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index b95d91b51..ba85ae9ac 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -16,7 +16,6 @@ import WebhookManager from "@/components/webhook/WebhookManager"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import CopyToClipboardButton from "@/components/CopyToClipboardButton"; -import CopyToClipboardButton from "@/components/CopyToClipboardButton"; import { useLocale, useTranslations } from "next-intl"; import { localeMetadata, locales, type AppLocale } from "@/i18n/config";