) => {
+ 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 && (
-
);
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;
+ }
+}