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');
+ });
+});