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.
This commit is contained in:
2026-03-06 13:26:04 +03:00
parent 02a586a1c5
commit 6da6c87d3c
6 changed files with 148 additions and 18 deletions

View File

@@ -57,7 +57,7 @@ export default function AdminPage() {
return (
<div className={PAGE_WRAPPER_CLASS}>
<header className="sticky top-0 z-10 flex flex-col items-center border-b bg-[var(--header-bg)] py-3">
<header className="sticky top-0 z-10 flex flex-col items-center border-b border-border bg-background py-3">
<div className="flex w-full items-center justify-between px-1">
<Button
type="button"
@@ -121,9 +121,11 @@ export default function AdminPage() {
{!admin.loading && !admin.error && (
<div className="mt-3 flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
{t("admin.reassign_duty")}: {t("admin.select_user")}
</p>
{admin.visibleGroups.length > 0 && (
<p className="text-sm text-muted-foreground">
{t("admin.reassign_duty")}: {t("admin.select_user")}
</p>
)}
<AdminDutyList
groups={admin.visibleGroups}
hasMore={admin.hasMore}

View File

@@ -78,14 +78,6 @@ export function AdminDutyList({
className="flex flex-col gap-1.5"
aria-label={t("admin.section_aria", { date: dateLabel })}
>
<p
className={cn(
"text-[0.75rem] font-medium text-muted m-0 pl-0.5",
isToday && "border-l-[3px] border-l-today pl-2 text-[var(--section-header)]"
)}
>
{dateLabel}
</p>
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
{duties.map((duty) => {
const dateStr = localDateString(new Date(duty.start_at));
@@ -101,9 +93,9 @@ export function AdminDutyList({
name: duty.full_name,
})}
className={cn(
"w-full min-h-0 rounded-lg border border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
"w-full min-h-0 rounded-lg border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5",
isToday ? "border-l-today" : "border-l-duty"
isToday ? "border-l-today bg-[var(--surface-today-tint)]" : "border-l-duty"
)}
>
<span>
@@ -111,7 +103,7 @@ export function AdminDutyList({
<span className="mx-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">{timeStr}</span>
</span>
<span>{duty.full_name}</span>
<span className="font-semibold">{duty.full_name}</span>
</button>
</li>
);

View File

@@ -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(
<ContactLinks phone="+79991234567" username={null} showLabels={false} />
);
const telLink = document.querySelector<HTMLAnchorElement>('a[href^="tel:"]');
expect(telLink).toBeInTheDocument();
fireEvent.click(telLink!);
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
});

View File

@@ -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<HTMLAnchorElement>) => {
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
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<a
href={`tel:${String(phone).trim()}`}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
@@ -99,6 +111,7 @@ export function ContactLinks({
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
{displayPhone}
</a>
@@ -109,6 +122,7 @@ export function ContactLinks({
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
{displayPhone}
</a>

View File

@@ -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<typeof vi.fn>;
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$/);
});
});

View File

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