From 24d6ecbedbd829c4d06d7662c73f0d55e4dac8b1 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Fri, 6 Mar 2026 18:55:56 +0300 Subject: [PATCH] feat: add copy functionality for phone and Telegram username in ContactLinks component - Implemented copy buttons for phone number and Telegram username in the ContactLinks component, enhancing user interaction. - Integrated tooltip feedback to indicate successful copy actions. - Updated tests to cover new copy functionality and ensure proper rendering of copy buttons based on props. - Added localization support for new copy-related strings in the i18n messages. --- .../components/contact/ContactLinks.test.tsx | 126 +++++++++++++++- .../src/components/contact/ContactLinks.tsx | 136 +++++++++++++++--- .../current-duty/CurrentDutyView.test.tsx | 34 +++++ .../current-duty/CurrentDutyView.tsx | 1 + webapp-next/src/i18n/messages.ts | 6 + webapp-next/src/lib/copy-to-clipboard.test.ts | 64 +++++++++ webapp-next/src/lib/copy-to-clipboard.ts | 41 ++++++ 7 files changed, 388 insertions(+), 20 deletions(-) create mode 100644 webapp-next/src/lib/copy-to-clipboard.test.ts create mode 100644 webapp-next/src/lib/copy-to-clipboard.ts diff --git a/webapp-next/src/components/contact/ContactLinks.test.tsx b/webapp-next/src/components/contact/ContactLinks.test.tsx index e38d04d..507a2e5 100644 --- a/webapp-next/src/components/contact/ContactLinks.test.tsx +++ b/webapp-next/src/components/contact/ContactLinks.test.tsx @@ -4,13 +4,19 @@ */ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { ContactLinks } from "./ContactLinks"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { resetAppStore } from "@/test/test-utils"; +function renderWithTooltip(ui: React.ReactElement) { + return render({ui}); +} + const openPhoneLinkMock = vi.fn(); const openTelegramProfileMock = vi.fn(); const triggerHapticLightMock = vi.fn(); +const copyToClipboardMock = vi.fn(); vi.mock("@/lib/open-phone-link", () => ({ openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args), @@ -21,6 +27,9 @@ vi.mock("@/lib/telegram-link", () => ({ vi.mock("@/lib/telegram-haptic", () => ({ triggerHapticLight: () => triggerHapticLightMock(), })); +vi.mock("@/lib/copy-to-clipboard", () => ({ + copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args), +})); describe("ContactLinks", () => { beforeEach(() => { @@ -28,6 +37,7 @@ describe("ContactLinks", () => { openPhoneLinkMock.mockClear(); openTelegramProfileMock.mockClear(); triggerHapticLightMock.mockClear(); + copyToClipboardMock.mockClear(); }); it("returns null when phone and username are missing", () => { @@ -100,4 +110,118 @@ describe("ContactLinks", () => { expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev"); expect(triggerHapticLightMock).toHaveBeenCalled(); }); + + describe("showCopyButtons with layout block", () => { + it("renders copy phone button with aria-label when phone is present", () => { + renderWithTooltip( + + ); + expect( + screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i }) + ).toBeInTheDocument(); + }); + + it("renders copy Telegram button with aria-label when username is present", () => { + renderWithTooltip( + + ); + expect( + screen.getByRole("button", { + name: /Copy Telegram username|Скопировать логин Telegram/i, + }) + ).toBeInTheDocument(); + }); + + it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => { + copyToClipboardMock.mockResolvedValue(true); + renderWithTooltip( + + ); + const copyBtn = screen.getByRole("button", { + name: /Copy phone number|Скопировать номер/i, + }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567"); + expect(triggerHapticLightMock).toHaveBeenCalled(); + }); + + it("calls copyToClipboard with @username when copy Telegram is clicked", async () => { + copyToClipboardMock.mockResolvedValue(true); + renderWithTooltip( + + ); + const copyBtn = screen.getByRole("button", { + name: /Copy Telegram username|Скопировать логин Telegram/i, + }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev"); + expect(triggerHapticLightMock).toHaveBeenCalled(); + }); + + it("shows Copied text in tooltip after successful copy", async () => { + copyToClipboardMock.mockResolvedValue(true); + renderWithTooltip( + + ); + const copyBtn = screen.getByRole("button", { + name: /Copy phone number|Скопировать номер/i, + }); + await act(async () => { + fireEvent.click(copyBtn); + }); + + const tooltip = await screen.findByRole("tooltip", { + name: /Copied|Скопировано/i, + }); + expect(tooltip).toBeInTheDocument(); + }); + + it("does not show copy buttons when showCopyButtons is false", () => { + render( + + ); + expect( + screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i }) + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/webapp-next/src/components/contact/ContactLinks.tsx b/webapp-next/src/components/contact/ContactLinks.tsx index faad19e..d5d234c 100644 --- a/webapp-next/src/components/contact/ContactLinks.tsx +++ b/webapp-next/src/components/contact/ContactLinks.tsx @@ -5,14 +5,23 @@ "use client"; +import { useState, useRef, useEffect } from "react"; import { useTranslation } from "@/i18n/use-translation"; import { formatPhoneDisplay } from "@/lib/phone-format"; import { openPhoneLink } from "@/lib/open-phone-link"; import { openTelegramProfile } from "@/lib/telegram-link"; import { triggerHapticLight } from "@/lib/telegram-haptic"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react"; +import { Phone as PhoneIcon, Send as TelegramIcon, Copy } from "lucide-react"; + +const COPIED_RESET_MS = 1800; export interface ContactLinksProps { phone?: string | null; @@ -21,6 +30,8 @@ export interface ContactLinksProps { showLabels?: boolean; /** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */ contextLabel?: string; + /** When true and layout is "block", show copy buttons for phone and Telegram username. */ + showCopyButtons?: boolean; className?: string; } @@ -36,14 +47,38 @@ export function ContactLinks({ layout = "inline", showLabels = true, contextLabel, + showCopyButtons = false, className, }: ContactLinksProps) { const { t } = useTranslation(); + const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null); + const copiedTimeoutRef = useRef | null>(null); + const hasPhone = Boolean(phone && String(phone).trim()); const rawUsername = username && String(username).trim(); const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : ""; const hasUsername = Boolean(cleanUsername); + const showCopy = layout === "block" && showCopyButtons; + + useEffect(() => () => clearCopiedTimeout(), []); + + const clearCopiedTimeout = () => { + if (copiedTimeoutRef.current) { + clearTimeout(copiedTimeoutRef.current); + copiedTimeoutRef.current = null; + } + }; + + const showCopiedFeedback = (kind: "phone" | "telegram") => { + clearCopiedTimeout(); + setCopiedKind(kind); + copiedTimeoutRef.current = setTimeout(() => { + setCopiedKind(null); + copiedTimeoutRef.current = null; + }, COPIED_RESET_MS); + }; + if (!hasPhone && !hasUsername) return null; const handlePhoneClick = (e: React.MouseEvent) => { @@ -52,6 +87,22 @@ export function ContactLinks({ triggerHapticLight(); }; + const handleCopyPhone = async (e: React.MouseEvent) => { + e.preventDefault(); + triggerHapticLight(); + const rawPhone = String(phone).trim(); + const ok = await copyToClipboard(rawPhone); + if (ok) showCopiedFeedback("phone"); + }; + + const handleCopyTelegram = async (e: React.MouseEvent) => { + e.preventDefault(); + triggerHapticLight(); + const text = `@${cleanUsername}`; + const ok = await copyToClipboard(text); + if (ok) showCopiedFeedback("telegram"); + }; + const ariaCall = contextLabel ? t("contact.aria_call", { name: contextLabel }) : t("contact.phone"); @@ -60,32 +111,52 @@ export function ContactLinks({ : t("contact.telegram"); if (layout === "block") { + const rowClass = + "flex h-12 items-center gap-0 rounded-md border border-input bg-background text-accent hover:bg-accent/10 hover:text-accent"; + return (
{hasPhone && ( - + {showCopy && ( + { + if (!open) setCopiedKind(null); + }} + > + + + + + {t("contact.copied")} + + + )} +
)} {hasUsername && ( - + {showCopy && ( + { + if (!open) setCopiedKind(null); + }} + > + + + + + {t("contact.copied")} + + + )} + )} ); diff --git a/webapp-next/src/components/current-duty/CurrentDutyView.test.tsx b/webapp-next/src/components/current-duty/CurrentDutyView.test.tsx index 0b75466..11f8ec7 100644 --- a/webapp-next/src/components/current-duty/CurrentDutyView.test.tsx +++ b/webapp-next/src/components/current-duty/CurrentDutyView.test.tsx @@ -6,6 +6,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { CurrentDutyView } from "./CurrentDutyView"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { resetAppStore } from "@/test/test-utils"; vi.mock("@/hooks/use-telegram-auth", () => ({ @@ -148,4 +149,37 @@ describe("CurrentDutyView", () => { expect(onBack).toHaveBeenCalled(); vi.mocked(fetchDuties).mockResolvedValue([]); }); + + it("shows copy phone and copy Telegram buttons when duty has contacts", async () => { + const { fetchDuties } = await import("@/lib/api"); + const now = new Date(); + const start = new Date(now.getTime() - 60 * 60 * 1000); + const end = new Date(now.getTime() + 60 * 60 * 1000); + const dutyWithContacts = { + id: 1, + user_id: 1, + start_at: start.toISOString(), + end_at: end.toISOString(), + event_type: "duty" as const, + full_name: "Test User", + phone: "+79991234567", + username: "testuser", + }; + vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]); + render( + + + + ); + await screen.findByText("Test User", {}, { timeout: 3000 }); + expect( + screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /Copy Telegram username|Скопировать логин Telegram/i, + }) + ).toBeInTheDocument(); + vi.mocked(fetchDuties).mockResolvedValue([]); + }); }); diff --git a/webapp-next/src/components/current-duty/CurrentDutyView.tsx b/webapp-next/src/components/current-duty/CurrentDutyView.tsx index 17a8253..21702b0 100644 --- a/webapp-next/src/components/current-duty/CurrentDutyView.tsx +++ b/webapp-next/src/components/current-duty/CurrentDutyView.tsx @@ -334,6 +334,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi layout="block" showLabels={true} contextLabel={duty.full_name ?? undefined} + showCopyButtons={true} /> ) : (

diff --git a/webapp-next/src/i18n/messages.ts b/webapp-next/src/i18n/messages.ts index d374921..713ecc1 100644 --- a/webapp-next/src/i18n/messages.ts +++ b/webapp-next/src/i18n/messages.ts @@ -62,6 +62,9 @@ export const MESSAGES: Record> = { "contact.telegram": "Telegram", "contact.aria_call": "Call {name}", "contact.aria_telegram": "Message {name} on Telegram", + "contact.copy_phone": "Copy phone number", + "contact.copy_telegram": "Copy Telegram username", + "contact.copied": "Copied", "current_duty.title": "Current Duty", "current_duty.no_duty": "No one is on duty right now", "current_duty.shift": "Shift", @@ -161,6 +164,9 @@ export const MESSAGES: Record> = { "contact.telegram": "Telegram", "contact.aria_call": "Позвонить {name}", "contact.aria_telegram": "Написать {name} в Telegram", + "contact.copy_phone": "Скопировать номер", + "contact.copy_telegram": "Скопировать логин Telegram", + "contact.copied": "Скопировано", "current_duty.title": "Сейчас дежурит", "current_duty.no_duty": "Сейчас никто не дежурит", "current_duty.shift": "Смена", diff --git a/webapp-next/src/lib/copy-to-clipboard.test.ts b/webapp-next/src/lib/copy-to-clipboard.test.ts new file mode 100644 index 0000000..0192897 --- /dev/null +++ b/webapp-next/src/lib/copy-to-clipboard.test.ts @@ -0,0 +1,64 @@ +/** + * Unit tests for copyToClipboard: Clipboard API and execCommand fallback. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +describe("copyToClipboard", () => { + const writeTextMock = vi.fn(); + + beforeEach(async () => { + vi.resetModules(); + writeTextMock.mockReset(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: writeTextMock }, + configurable: true, + writable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns true when navigator.clipboard.writeText succeeds", async () => { + writeTextMock.mockResolvedValue(undefined); + const { copyToClipboard } = await import("./copy-to-clipboard"); + + const result = await copyToClipboard("+79991234567"); + + expect(result).toBe(true); + expect(writeTextMock).toHaveBeenCalledWith("+79991234567"); + }); + + it("returns false when text is empty", async () => { + const { copyToClipboard } = await import("./copy-to-clipboard"); + + expect(await copyToClipboard("")).toBe(false); + expect(await copyToClipboard(" ")).toBe(false); + expect(writeTextMock).not.toHaveBeenCalled(); + }); + + it("returns false when clipboard.writeText rejects", async () => { + writeTextMock.mockRejectedValue(new Error("Permission denied")); + const { copyToClipboard } = await import("./copy-to-clipboard"); + + const result = await copyToClipboard("hello"); + + expect(result).toBe(false); + }); + + it("uses execCommand fallback when clipboard is missing", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + const execCommandMock = vi.fn().mockReturnValue(true); + (document as unknown as { execCommand?: ReturnType }).execCommand = + execCommandMock; + vi.resetModules(); + const { copyToClipboard } = await import("./copy-to-clipboard"); + + const result = await copyToClipboard("fallback text"); + + expect(result).toBe(true); + expect(execCommandMock).toHaveBeenCalledWith("copy"); + }); +}); diff --git a/webapp-next/src/lib/copy-to-clipboard.ts b/webapp-next/src/lib/copy-to-clipboard.ts new file mode 100644 index 0000000..ef25852 --- /dev/null +++ b/webapp-next/src/lib/copy-to-clipboard.ts @@ -0,0 +1,41 @@ +/** + * Copy text to the clipboard. Uses Clipboard API with execCommand fallback. + * Used for copying phone/Telegram on the current duty view. + */ + +/** + * Copies the given text to the clipboard. + * + * @param text - Raw text to copy (e.g. phone number or @username). + * @returns Promise resolving to true on success, false on failure. Errors are logged, not thrown. + */ +export async function copyToClipboard(text: string): Promise { + if (typeof text !== "string" || text.trim() === "") { + return false; + } + + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (err) { + console.warn("clipboard.writeText failed", err); + } + } + + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.setAttribute("readonly", ""); + textarea.style.position = "absolute"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch (err) { + console.warn("execCommand copy fallback failed", err); + return false; + } +}