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:
@@ -57,7 +57,7 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={PAGE_WRAPPER_CLASS}>
|
<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">
|
<div className="flex w-full items-center justify-between px-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -121,9 +121,11 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
{!admin.loading && !admin.error && (
|
{!admin.loading && !admin.error && (
|
||||||
<div className="mt-3 flex flex-col gap-2">
|
<div className="mt-3 flex flex-col gap-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
{admin.visibleGroups.length > 0 && (
|
||||||
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
<p className="text-sm text-muted-foreground">
|
||||||
</p>
|
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<AdminDutyList
|
<AdminDutyList
|
||||||
groups={admin.visibleGroups}
|
groups={admin.visibleGroups}
|
||||||
hasMore={admin.hasMore}
|
hasMore={admin.hasMore}
|
||||||
|
|||||||
@@ -78,14 +78,6 @@ export function AdminDutyList({
|
|||||||
className="flex flex-col gap-1.5"
|
className="flex flex-col gap-1.5"
|
||||||
aria-label={t("admin.section_aria", { date: dateLabel })}
|
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">
|
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
|
||||||
{duties.map((duty) => {
|
{duties.map((duty) => {
|
||||||
const dateStr = localDateString(new Date(duty.start_at));
|
const dateStr = localDateString(new Date(duty.start_at));
|
||||||
@@ -101,9 +93,9 @@ export function AdminDutyList({
|
|||||||
name: duty.full_name,
|
name: duty.full_name,
|
||||||
})}
|
})}
|
||||||
className={cn(
|
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",
|
"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>
|
<span>
|
||||||
@@ -111,7 +103,7 @@ export function AdminDutyList({
|
|||||||
<span className="mx-2 text-muted-foreground">·</span>
|
<span className="mx-2 text-muted-foreground">·</span>
|
||||||
<span className="text-muted-foreground">{timeStr}</span>
|
<span className="text-muted-foreground">{timeStr}</span>
|
||||||
</span>
|
</span>
|
||||||
<span>{duty.full_name}</span>
|
<span className="font-semibold">{duty.full_name}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,14 +3,26 @@
|
|||||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { ContactLinks } from "./ContactLinks";
|
import { ContactLinks } from "./ContactLinks";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
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", () => {
|
describe("ContactLinks", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAppStore();
|
resetAppStore();
|
||||||
|
openPhoneLinkMock.mockClear();
|
||||||
|
triggerHapticLightMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when phone and username are missing", () => {
|
it("returns null when phone and username are missing", () => {
|
||||||
@@ -57,4 +69,17 @@ describe("ContactLinks", () => {
|
|||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
expect(link?.textContent).toContain("@alice");
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
||||||
@@ -43,6 +45,12 @@ export function ContactLinks({
|
|||||||
|
|
||||||
if (!hasPhone && !hasUsername) return null;
|
if (!hasPhone && !hasUsername) return null;
|
||||||
|
|
||||||
|
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openPhoneLink(phone ?? undefined);
|
||||||
|
triggerHapticLight();
|
||||||
|
};
|
||||||
|
|
||||||
const ariaCall = contextLabel
|
const ariaCall = contextLabel
|
||||||
? t("contact.aria_call", { name: contextLabel })
|
? t("contact.aria_call", { name: contextLabel })
|
||||||
: t("contact.phone");
|
: 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"
|
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||||
asChild
|
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 />
|
<PhoneIcon className="size-5" aria-hidden />
|
||||||
<span>{formatPhoneDisplay(phone!)}</span>
|
<span>{formatPhoneDisplay(phone!)}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -99,6 +111,7 @@ export function ContactLinks({
|
|||||||
href={`tel:${String(phone).trim()}`}
|
href={`tel:${String(phone).trim()}`}
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaCall}
|
aria-label={ariaCall}
|
||||||
|
onClick={handlePhoneClick}
|
||||||
>
|
>
|
||||||
{displayPhone}
|
{displayPhone}
|
||||||
</a>
|
</a>
|
||||||
@@ -109,6 +122,7 @@ export function ContactLinks({
|
|||||||
href={`tel:${String(phone).trim()}`}
|
href={`tel:${String(phone).trim()}`}
|
||||||
className={linkClass}
|
className={linkClass}
|
||||||
aria-label={ariaCall}
|
aria-label={ariaCall}
|
||||||
|
onClick={handlePhoneClick}
|
||||||
>
|
>
|
||||||
{displayPhone}
|
{displayPhone}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
51
webapp-next/src/lib/open-phone-link.test.ts
Normal file
51
webapp-next/src/lib/open-phone-link.test.ts
Normal 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$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
webapp-next/src/lib/open-phone-link.ts
Normal file
46
webapp-next/src/lib/open-phone-link.ts
Normal 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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user