- Updated theme resolution logic to utilize a shared inline script for consistent theme application across routes. - Introduced `AppShell` and `ReadyGate` components to manage app readiness and theme synchronization, improving user experience. - Enhanced `GlobalError` and `NotFound` pages with a unified full-screen layout for better accessibility and visual consistency. - Refactored CSS to implement safe area insets for sticky headers and content safety, ensuring proper layout on various devices. - Added unit tests for new functionality and improved existing tests for better coverage and reliability.
172 lines
5.1 KiB
TypeScript
172 lines
5.1 KiB
TypeScript
/**
|
|
* Contact links (phone, Telegram) for duty cards and day detail.
|
|
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useTranslation } from "@/i18n/use-translation";
|
|
import { formatPhoneDisplay } from "@/lib/phone-format";
|
|
import { openPhoneLink } from "@/lib/open-phone-link";
|
|
import { openTelegramProfile } from "@/lib/telegram-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";
|
|
|
|
export interface ContactLinksProps {
|
|
phone?: string | null;
|
|
username?: string | null;
|
|
layout?: "inline" | "block";
|
|
showLabels?: boolean;
|
|
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
|
contextLabel?: string;
|
|
className?: string;
|
|
}
|
|
|
|
const linkClass =
|
|
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
|
|
|
/**
|
|
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
|
*/
|
|
export function ContactLinks({
|
|
phone,
|
|
username,
|
|
layout = "inline",
|
|
showLabels = true,
|
|
contextLabel,
|
|
className,
|
|
}: ContactLinksProps) {
|
|
const { t } = useTranslation();
|
|
const hasPhone = Boolean(phone && String(phone).trim());
|
|
const rawUsername = username && String(username).trim();
|
|
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
|
const hasUsername = Boolean(cleanUsername);
|
|
|
|
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");
|
|
const ariaTelegram = contextLabel
|
|
? t("contact.aria_telegram", { name: contextLabel })
|
|
: t("contact.telegram");
|
|
|
|
if (layout === "block") {
|
|
return (
|
|
<div className={cn("flex flex-col gap-2", className)}>
|
|
{hasPhone && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
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}
|
|
onClick={handlePhoneClick}
|
|
>
|
|
<PhoneIcon className="size-5" aria-hidden />
|
|
<span>{formatPhoneDisplay(phone!)}</span>
|
|
</a>
|
|
</Button>
|
|
)}
|
|
{hasUsername && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
|
asChild
|
|
>
|
|
<a
|
|
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label={ariaTelegram}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
openTelegramProfile(cleanUsername);
|
|
triggerHapticLight();
|
|
}}
|
|
>
|
|
<TelegramIcon className="size-5" aria-hidden />
|
|
<span>@{cleanUsername}</span>
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const parts: React.ReactNode[] = [];
|
|
if (hasPhone) {
|
|
const displayPhone = formatPhoneDisplay(phone!);
|
|
parts.push(
|
|
showLabels ? (
|
|
<span key="phone">
|
|
{t("contact.phone")}:{" "}
|
|
<a
|
|
href={`tel:${String(phone).trim()}`}
|
|
className={linkClass}
|
|
aria-label={ariaCall}
|
|
onClick={handlePhoneClick}
|
|
>
|
|
{displayPhone}
|
|
</a>
|
|
</span>
|
|
) : (
|
|
<a
|
|
key="phone"
|
|
href={`tel:${String(phone).trim()}`}
|
|
className={linkClass}
|
|
aria-label={ariaCall}
|
|
onClick={handlePhoneClick}
|
|
>
|
|
{displayPhone}
|
|
</a>
|
|
)
|
|
);
|
|
}
|
|
if (hasUsername) {
|
|
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
|
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
e.preventDefault();
|
|
openTelegramProfile(cleanUsername);
|
|
triggerHapticLight();
|
|
};
|
|
const link = (
|
|
<a
|
|
key="tg"
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={linkClass}
|
|
aria-label={ariaTelegram}
|
|
onClick={handleTelegramClick}
|
|
>
|
|
@{cleanUsername}
|
|
</a>
|
|
);
|
|
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
|
}
|
|
|
|
return (
|
|
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
|
{parts.map((p, i) => (
|
|
<span key={i} className="inline-flex items-center gap-x-1">
|
|
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
|
{p}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|