Files
duty-teller/webapp-next/src/components/current-duty/CurrentDutyView.tsx
Nikolay Tatarinov 24d6ecbedb feat: add copy functionality for phone and Telegram username in ContactLinks component
- Implemented copy buttons for phone number and Telegram username in the ContactLinks component, enhancing user interaction.
- Integrated tooltip feedback to indicate successful copy actions.
- Updated tests to cover new copy functionality and ensure proper rendering of copy buttons based on props.
- Added localization support for new copy-related strings in the i18n messages.
2026-03-06 18:55:56 +03:00

358 lines
12 KiB
TypeScript

/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
* and contact links. Integrates with Telegram BackButton.
* Ported from webapp/js/currentDuty.js.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { translate } from "@/i18n/messages";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { fetchDuties, AccessDeniedError } from "@/lib/api";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import type { DutyWithUser } from "@/types";
import { useTelegramBackButton, useTelegramCloseAction } from "@/hooks/telegram";
import { useScreenReady } from "@/hooks/use-screen-ready";
import { useRequestState } from "@/hooks/use-request-state";
export interface CurrentDutyViewProps {
/** Called when user taps Back (in-app button or Telegram BackButton). */
onBack: () => void;
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
openedFromPin?: boolean;
}
/**
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
*/
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation();
const lang = useAppStore((s) => s.lang);
const { initDataRaw } = useTelegramAuth();
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const {
state: requestState,
setLoading,
setSuccess,
setError,
setAccessDenied,
isLoading,
isError,
isAccessDenied,
} = useRequestState("loading");
const loadTodayDuties = useCallback(
async (signal?: AbortSignal | null) => {
const today = new Date();
const from = localDateString(today);
const to = from;
const initData = initDataRaw ?? "";
try {
const duties = await fetchDuties(from, to, initData, lang, signal);
if (signal?.aborted) return;
const active = findCurrentDuty(duties);
setDuty(active);
setSuccess();
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
setRemaining(null);
}
} catch (e) {
if (signal?.aborted) return;
if (e instanceof AccessDeniedError) {
setAccessDenied(e.serverDetail ?? null);
setDuty(null);
setRemaining(null);
} else {
setError(translate(lang, "error_generic"));
setDuty(null);
setRemaining(null);
}
}
},
[initDataRaw, lang, setSuccess, setAccessDenied, setError]
);
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
useEffect(() => {
const controller = new AbortController();
loadTodayDuties(controller.signal);
return () => controller.abort();
}, [loadTodayDuties]);
useScreenReady(!isLoading);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
if (!duty) return;
const interval = setInterval(() => {
setRemaining(getRemainingTime(duty.end_at));
}, 1000);
return () => clearInterval(interval);
}, [duty]);
useTelegramBackButton({
enabled: true,
onClick: onBack,
});
const handleBack = () => {
triggerHapticLight();
onBack();
};
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
const handleClose = () => {
triggerHapticLight();
closeMiniAppOrFallback();
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
const primaryButtonAriaLabel = openedFromPin
? t("current_duty.close")
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (isLoading) {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-full max-w-[200px]" />
<Skeleton className="h-4 w-full max-w-[280px]" />
</div>
<Skeleton className="h-14 w-full rounded-lg" />
<div className="flex flex-col gap-2 border-t border-border/50 pt-4">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (isAccessDenied) {
return (
<AccessDeniedScreen
serverDetail={requestState.accessDeniedDetail}
primaryAction="back"
onBack={handlePrimaryAction}
openedFromPin={openedFromPin}
/>
);
}
if (isError) {
const handleRetry = () => {
triggerHapticLight();
setLoading();
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6">
<p className="text-error">{requestState.error}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="default"
onClick={handleRetry}
aria-label={t("error.retry")}
>
{t("error.retry")}
</Button>
<Button
variant="outline"
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (!duty) {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
<CardHeader>
<CardTitle>{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<span
className="flex items-center justify-center text-muted-foreground"
aria-hidden
>
<Calendar className="size-12" strokeWidth={1.5} />
</span>
<p className="text-center text-muted-foreground">
{t("current_duty.no_duty")}
</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
<Button
variant="outline"
onClick={onBack}
aria-label={t("current_duty.open_calendar")}
>
{t("current_duty.open_calendar")}
</Button>
</CardFooter>
</Card>
</div>
);
}
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
const endTime = formatHHMM(duty.end_at);
const shiftStr = `${startDDMM} ${startTime}${endDDMM} ${endTime}`;
const rem = remaining ?? getRemainingTime(duty.end_at);
const remainingValueStr = t("current_duty.remaining_value", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
const displayTz =
typeof window !== "undefined" &&
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
const shiftLabel = displayTz
? t("current_duty.shift_tz", { tz: displayTz })
: t("current_duty.shift_local");
const hasContacts =
Boolean(duty.phone && String(duty.phone).trim()) ||
Boolean(duty.username && String(duty.username).trim());
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
role="article"
aria-labelledby="current-duty-title"
>
<CardHeader className="sr-only">
<CardTitle id="current-duty-title">{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-6">
<span
className="inline-flex w-fit items-center gap-2 rounded-full bg-duty/15 px-2.5 py-1 text-xs font-medium text-foreground"
aria-hidden
>
<span className="size-2 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none" />
{t("duty.now_on_duty")}
</span>
<section className="flex flex-col gap-2" aria-label={t("current_duty.shift")}>
<p
className="text-xl font-bold text-foreground leading-tight"
id="current-duty-name"
>
{duty.full_name}
</p>
<p className="text-sm text-muted-foreground break-words">
{shiftLabel}
</p>
<p className="text-sm text-muted-foreground break-words">
{shiftStr}
</p>
</section>
<div
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
aria-label={t("current_duty.remaining_label")}
>
<span className="text-xs text-muted-foreground">
{t("current_duty.remaining_label")}
</span>
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
{remainingValueStr}
</span>
<span className="text-xs text-muted-foreground">
{t("current_duty.ends_at", { time: formatHHMM(duty.end_at) })}
</span>
</div>
<section className="flex flex-col gap-2 border-t border-border/50 pt-4" aria-label={t("contact.label")}>
{hasContacts ? (
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
showCopyButtons={true}
/>
) : (
<p className="text-sm text-muted-foreground">
{t("current_duty.contact_info_not_set")}
</p>
)}
</section>
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}