diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..0610cc1 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Copy, Check } from 'lucide-react'; +import { copyToClipboard } from '../lib/utils/clipboard'; +import { toast } from 'sonner'; + +interface CopyButtonProps { + text: string; + label?: string; + size?: number; + successMessage?: string; + showText?: boolean; +} + +export function CopyButton({ text, label = 'Copy', size = 11, successMessage = 'Copied!', showText = false }: CopyButtonProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + const success = await copyToClipboard(text); + if (success) { + setCopied(true); + toast.success(successMessage); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + ); +} diff --git a/src/lib/utils/clipboard.ts b/src/lib/utils/clipboard.ts new file mode 100644 index 0000000..2b2daff --- /dev/null +++ b/src/lib/utils/clipboard.ts @@ -0,0 +1,42 @@ +import { toast } from 'sonner'; + +/** + * Copies text to clipboard with a fallback for older browsers or environments + * where navigator.clipboard is unavailable (like HTTP without localhost). + */ +export async function copyToClipboard(text: string): Promise { + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + + // Fallback using execCommand for unsupported contexts + const textArea = document.createElement('textarea'); + textArea.value = text; + + // Avoid scrolling to bottom + textArea.style.top = '0'; + textArea.style.left = '0'; + textArea.style.position = 'fixed'; + + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (!successful) { + throw new Error('Fallback copy failed'); + } + return true; + } catch (error) { + if (error instanceof Error && error.name === 'NotAllowedError') { + toast.error('Clipboard permission denied'); + } else { + toast.error('Failed to copy to clipboard'); + } + return false; + } +} diff --git a/src/pages/app/ProfilePage.tsx b/src/pages/app/ProfilePage.tsx index 90b45a5..8858026 100644 --- a/src/pages/app/ProfilePage.tsx +++ b/src/pages/app/ProfilePage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Copy, Check, ChevronRight, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen } from 'lucide-react'; +import { ChevronRight, Bell, Trash2, Download, Calculator, AlertTriangle, LogOut, ExternalLink, MessageSquare, Calendar, Plus, Users, Mail, Loader2, Heart, Star, BookOpen } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { useQuery } from '@tanstack/react-query'; import { NavBar } from '../../components/NavBar'; @@ -12,9 +12,9 @@ import { isPushSupported, getPushPermission, hasActiveSubscription, subscribeToP import { FeedbackSheet } from '../../components/FeedbackSheet'; import { signOutGlobal } from '../../components/AuthProvider'; import { useUserTags, useDeleteTag, MAX_ACTIVE_TAGS } from '../../hooks/useUserTags'; -import { TagPill } from '../../components/TagPill'; import { AddTagSheet } from '../../components/AddTagSheet'; import { BottomSheet } from '../../components/BottomSheet'; +import { CopyButton } from '../../components/CopyButton'; import { logEvent } from '../../lib/analytics'; export default function ProfilePage() { @@ -137,15 +137,6 @@ export default function ProfilePage() { const initials = displayName.split(' ').map((n: string) => n[0]).join('').slice(0, 2).toUpperCase(); - const [copied, setCopied] = useState(false); - - const handleCopy = () => { - navigator.clipboard.writeText(hubCode); - setCopied(true); - toast.success('Hub code copied!'); - setTimeout(() => setCopied(false), 2000); - }; - const handleDeleteAccount = async () => { if (deleteInput !== 'DELETE') return; setDeleting(true); @@ -404,8 +395,9 @@ export default function ProfilePage() { {authUser.branch} )} {displayRole !== 'teacher' && ( - + Roll {classRoll} + {classRoll !== '—' && } )} @@ -483,15 +475,7 @@ export default function ProfilePage() { label: 'Hub Code', value: hubCode, action: ( - + ) }, { @@ -527,7 +511,11 @@ export default function ProfilePage() { ) }, ...(role !== 'teacher' ? [ - { label: 'University Roll', value: universityRoll }, + { + label: 'University Roll', + value: universityRoll, + action: universityRoll !== '—' ? : undefined + }, { label: 'Curriculum', value: 'Subjects', diff --git a/src/pages/app/SectionDirectoryPage.tsx b/src/pages/app/SectionDirectoryPage.tsx index 04bf81e..319b760 100644 --- a/src/pages/app/SectionDirectoryPage.tsx +++ b/src/pages/app/SectionDirectoryPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useRef, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, X, Users, Mail, BookOpen, Copy } from 'lucide-react'; +import { ArrowLeft, X, Users, Mail, BookOpen } from 'lucide-react'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; import Skeleton from 'react-loading-skeleton'; import { useQuery } from '@tanstack/react-query'; @@ -10,6 +10,7 @@ import { useSectionMembers, useSection } from '../../hooks/useSectionMembers'; import { useUserTagsBatch, useDeleteTag } from '../../hooks/useUserTags'; import { useAppStore } from '../../store/appStore'; import { TagPill, TagOverflow } from '../../components/TagPill'; +import { CopyButton } from '../../components/CopyButton'; import { toast } from 'sonner'; const MAX_VISIBLE_TAGS = 3; @@ -306,30 +307,7 @@ export default function SectionDirectoryPage() { +91 {teacher.phone} - + )} @@ -518,6 +496,9 @@ export default function SectionDirectoryPage() { {member.classRoll && ( {member.classRoll} + )} {member.role === 'cr' && ( @@ -547,30 +529,7 @@ export default function SectionDirectoryPage() { +91 {member.phone} - + )} diff --git a/tests/unit/clipboard.test.ts b/tests/unit/clipboard.test.ts new file mode 100644 index 0000000..5983cbb --- /dev/null +++ b/tests/unit/clipboard.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { copyToClipboard } from '../../src/lib/utils/clipboard'; +import { toast } from 'sonner'; + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + } +})); + +describe('clipboard util', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('uses navigator.clipboard if available', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + configurable: true, + }); + // mock window.isSecureContext + Object.defineProperty(window, 'isSecureContext', { value: true, writable: true }); + + const result = await copyToClipboard('test text'); + + expect(mockWriteText).toHaveBeenCalledWith('test text'); + expect(result).toBe(true); + }); + + it('falls back to execCommand if navigator.clipboard is unavailable', async () => { + // hide navigator.clipboard + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + configurable: true, + }); + Object.defineProperty(window, 'isSecureContext', { value: false, writable: true }); + + const mockExecCommand = vi.fn().mockReturnValue(true); + document.execCommand = mockExecCommand; + + const result = await copyToClipboard('test text fallback'); + + expect(mockExecCommand).toHaveBeenCalledWith('copy'); + expect(result).toBe(true); + }); + + it('handles permission denied error', async () => { + const mockWriteText = vi.fn().mockRejectedValue(new DOMException('Not allowed', 'NotAllowedError')); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + configurable: true, + }); + Object.defineProperty(window, 'isSecureContext', { value: true, writable: true }); + + const result = await copyToClipboard('test text'); + + expect(result).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Clipboard permission denied'); + }); + + it('handles generic fallback error', async () => { + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + configurable: true, + }); + Object.defineProperty(window, 'isSecureContext', { value: false, writable: true }); + const mockExecCommand = vi.fn().mockReturnValue(false); + document.execCommand = mockExecCommand; + + const result = await copyToClipboard('test text'); + + expect(result).toBe(false); + expect(toast.error).toHaveBeenCalledWith('Failed to copy to clipboard'); + }); +});