From 6da6c87d3cde1bd305367317c7d28d3bca29dda6 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Fri, 6 Mar 2026 13:26:04 +0300 Subject: [PATCH] feat: enhance admin and contact components with new functionality - Updated `AdminPage` to conditionally display duty reassignment instructions based on visible groups, improving user guidance. - Refactored `AdminDutyList` to streamline the display of duties, enhancing visual clarity and organization. - Introduced `openPhoneLink` and `triggerHapticLight` functions in `ContactLinks` for improved phone link interaction and haptic feedback. - Added unit tests for `openPhoneLink` to ensure correct functionality and handling of various phone number formats. - Enhanced existing tests for `ContactLinks` to verify new phone link behavior, ensuring robust testing coverage. --- webapp-next/src/app/admin/page.tsx | 10 ++-- .../src/components/admin/AdminDutyList.tsx | 14 ++--- .../components/contact/ContactLinks.test.tsx | 29 ++++++++++- .../src/components/contact/ContactLinks.tsx | 16 +++++- webapp-next/src/lib/open-phone-link.test.ts | 51 +++++++++++++++++++ webapp-next/src/lib/open-phone-link.ts | 46 +++++++++++++++++ 6 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 webapp-next/src/lib/open-phone-link.test.ts create mode 100644 webapp-next/src/lib/open-phone-link.ts diff --git a/webapp-next/src/app/admin/page.tsx b/webapp-next/src/app/admin/page.tsx index bd10418..1222120 100644 --- a/webapp-next/src/app/admin/page.tsx +++ b/webapp-next/src/app/admin/page.tsx @@ -57,7 +57,7 @@ export default function AdminPage() { return (
-
+
); diff --git a/webapp-next/src/components/contact/ContactLinks.test.tsx b/webapp-next/src/components/contact/ContactLinks.test.tsx index b00216c..244e07d 100644 --- a/webapp-next/src/components/contact/ContactLinks.test.tsx +++ b/webapp-next/src/components/contact/ContactLinks.test.tsx @@ -3,14 +3,26 @@ * Ported from webapp/js/contactHtml.test.js buildContactLinksHtml. */ -import { describe, it, expect, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; import { ContactLinks } from "./ContactLinks"; import { resetAppStore } from "@/test/test-utils"; +const openPhoneLinkMock = vi.fn(); +const triggerHapticLightMock = vi.fn(); + +vi.mock("@/lib/open-phone-link", () => ({ + openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args), +})); +vi.mock("@/lib/telegram-haptic", () => ({ + triggerHapticLight: () => triggerHapticLightMock(), +})); + describe("ContactLinks", () => { beforeEach(() => { resetAppStore(); + openPhoneLinkMock.mockClear(); + triggerHapticLightMock.mockClear(); }); it("returns null when phone and username are missing", () => { @@ -57,4 +69,17 @@ describe("ContactLinks", () => { expect(link).toBeInTheDocument(); expect(link?.textContent).toContain("@alice"); }); + + it("calls openPhoneLink and triggerHapticLight when phone link is clicked", () => { + render( + + ); + const telLink = document.querySelector('a[href^="tel:"]'); + expect(telLink).toBeInTheDocument(); + + fireEvent.click(telLink!); + + expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567"); + expect(triggerHapticLightMock).toHaveBeenCalled(); + }); }); diff --git a/webapp-next/src/components/contact/ContactLinks.tsx b/webapp-next/src/components/contact/ContactLinks.tsx index 10a7cc2..e1e12e5 100644 --- a/webapp-next/src/components/contact/ContactLinks.tsx +++ b/webapp-next/src/components/contact/ContactLinks.tsx @@ -7,6 +7,8 @@ import { useTranslation } from "@/i18n/use-translation"; import { formatPhoneDisplay } from "@/lib/phone-format"; +import { openPhoneLink } from "@/lib/open-phone-link"; +import { triggerHapticLight } from "@/lib/telegram-haptic"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react"; @@ -43,6 +45,12 @@ export function ContactLinks({ if (!hasPhone && !hasUsername) return null; + const handlePhoneClick = (e: React.MouseEvent) => { + e.preventDefault(); + openPhoneLink(phone ?? undefined); + triggerHapticLight(); + }; + const ariaCall = contextLabel ? t("contact.aria_call", { name: contextLabel }) : t("contact.phone"); @@ -60,7 +68,11 @@ export function ContactLinks({ className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent" asChild > - + {formatPhoneDisplay(phone!)} @@ -99,6 +111,7 @@ export function ContactLinks({ href={`tel:${String(phone).trim()}`} className={linkClass} aria-label={ariaCall} + onClick={handlePhoneClick} > {displayPhone} @@ -109,6 +122,7 @@ export function ContactLinks({ href={`tel:${String(phone).trim()}`} className={linkClass} aria-label={ariaCall} + onClick={handlePhoneClick} > {displayPhone} diff --git a/webapp-next/src/lib/open-phone-link.test.ts b/webapp-next/src/lib/open-phone-link.test.ts new file mode 100644 index 0000000..1c9b03e --- /dev/null +++ b/webapp-next/src/lib/open-phone-link.test.ts @@ -0,0 +1,51 @@ +/** + * Unit tests for openPhoneLink: creates a temporary tel: link and triggers click. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { openPhoneLink } from "./open-phone-link"; + +describe("openPhoneLink", () => { + let appendedAnchor: HTMLAnchorElement | null; + let anchorClickSpy: ReturnType; + + beforeEach(() => { + appendedAnchor = null; + anchorClickSpy = vi.fn(); + vi.spyOn(window, "open").mockReturnValue(null); + vi.spyOn(document.body, "appendChild").mockImplementation((node: Node) => { + const el = node as HTMLAnchorElement; + if (el.tagName === "A" && el.href) { + appendedAnchor = el; + el.click = anchorClickSpy; + } + return node; + }); + vi.spyOn(document.body, "removeChild").mockImplementation((node: Node) => node); + }); + + it("does nothing when phone is null or empty", () => { + openPhoneLink(null); + openPhoneLink(""); + openPhoneLink(" "); + expect(appendedAnchor).toBeNull(); + expect(anchorClickSpy).not.toHaveBeenCalled(); + }); + + it("creates anchor with tel URL and triggers click for valid phone", () => { + openPhoneLink("+79991234567"); + expect(appendedAnchor).not.toBeNull(); + expect(appendedAnchor!.href).toMatch(/tel:\+79991234567$/); + expect(anchorClickSpy).toHaveBeenCalled(); + }); + + it("builds correct tel URL for 10-digit Russian number", () => { + openPhoneLink("9146522209"); + expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/); + }); + + it("builds correct tel URL for 11-digit number starting with 8", () => { + openPhoneLink("89146522209"); + expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/); + }); +}); diff --git a/webapp-next/src/lib/open-phone-link.ts b/webapp-next/src/lib/open-phone-link.ts new file mode 100644 index 0000000..3533773 --- /dev/null +++ b/webapp-next/src/lib/open-phone-link.ts @@ -0,0 +1,46 @@ +/** + * Opens a phone number for calling. Uses native tel: navigation so the OS + * or WebView can open the dialer. Does not use Telegram openLink() for tel: + * (protocol is not supported; it closes the app on desktop and does nothing on mobile). + */ + +/** + * Builds a tel: URL from a phone string (digits and optional leading + only). + */ +function buildTelUrl(phone: string): string { + const trimmed = String(phone).trim(); + if (trimmed === "") return ""; + const digits = trimmed.replace(/\D/g, ""); + if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) { + return `tel:+7${digits.slice(1)}`; + } + if (digits.length === 10) { + return `tel:+7${digits}`; + } + return `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}`; +} + +/** + * Opens the given phone number for calling. Tries window.open(telUrl) first + * (reported to work for tel: in some Telegram WebViews), then a programmatic + * click on a temporary tel: link. openLink(tel:) is not used — Telegram does + * not support it (closes app on desktop, no dialer on mobile). Safe to call + * from click handlers; does not throw. + */ +export function openPhoneLink(phone: string | null | undefined): void { + if (phone == null || String(phone).trim() === "") return; + const telUrl = buildTelUrl(phone); + if (telUrl === "") return; + if (typeof window === "undefined" || !window.document) return; + + const opened = window.open(telUrl); + if (opened !== null) return; + + const anchor = window.document.createElement("a"); + anchor.href = telUrl; + anchor.style.display = "none"; + anchor.setAttribute("aria-hidden", "true"); + window.document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +}