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