feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability. - Updated the `.gitignore` to exclude Next.js build artifacts and node modules. - Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack. - Enhanced Dockerfile to support the new build process for the Next.js application. - Updated CI workflow to build and test the Next.js application. - Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking. - Refactored frontend testing setup to accommodate the new structure and testing framework. - Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* 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 { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
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 { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
type ViewState = "loading" | "error" | "ready";
|
||||
|
||||
/**
|
||||
* 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 [state, setState] = useState<ViewState>("loading");
|
||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||
|
||||
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);
|
||||
setState("ready");
|
||||
if (active) {
|
||||
setRemaining(getRemainingTime(active.end_at));
|
||||
} else {
|
||||
setRemaining(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
setState("error");
|
||||
const msg =
|
||||
e instanceof AccessDeniedError && e.serverDetail
|
||||
? e.serverDetail
|
||||
: t("error_generic");
|
||||
setErrorMessage(msg);
|
||||
setDuty(null);
|
||||
setRemaining(null);
|
||||
}
|
||||
},
|
||||
[initDataRaw, lang, t]
|
||||
);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Signal Telegram to hide loading when this view is ready (or error).
|
||||
useEffect(() => {
|
||||
if (state !== "loading") {
|
||||
callMiniAppReadyOnce();
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Telegram BackButton: show on mount, hide on unmount, handle click.
|
||||
useEffect(() => {
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
backButton.mount();
|
||||
}
|
||||
if (backButton.show.isAvailable()) {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(onBack);
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (typeof offClick === "function") offClick();
|
||||
if (backButton.hide.isAvailable()) {
|
||||
backButton.hide();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [onBack]);
|
||||
|
||||
const handleBack = () => {
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (closeMiniApp.isAvailable()) {
|
||||
closeMiniApp();
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
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 (state === "loading") {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<span
|
||||
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
||||
aria-hidden
|
||||
/>
|
||||
<p className="text-muted-foreground m-0">{t("loading")}</p>
|
||||
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
const handleRetry = () => {
|
||||
setState("loading");
|
||||
loadTodayDuties();
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||
<Card className="w-full max-w-[var(--max-width-app)]">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-error">{errorMessage}</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 px-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>
|
||||
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</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 remainingStr = t("current_duty.remaining", {
|
||||
hours: String(rem.hours),
|
||||
minutes: String(rem.minutes),
|
||||
});
|
||||
const endsAtStr = t("current_duty.ends_at", { time: endTime });
|
||||
|
||||
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 px-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>
|
||||
<CardTitle
|
||||
id="current-duty-title"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("current_duty.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<p className="font-medium text-foreground" id="current-duty-name">
|
||||
{duty.full_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{shiftLabel} {shiftStr}
|
||||
</p>
|
||||
<div
|
||||
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{remainingStr}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
|
||||
{hasContacts ? (
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="block"
|
||||
showLabels={true}
|
||||
contextLabel={duty.full_name ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("current_duty.contact_info_not_set")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user