feat: enhance Mini App design guidelines and refactor layout components

- Updated Mini App design guidelines to include detailed instructions on UI changes, accessibility rules, and verification processes.
- Refactored multiple components to utilize `MiniAppScreen` and `MiniAppScreenContent` for consistent layout structure across the application.
- Improved error handling in `GlobalError` and `NotFound` components by integrating new layout components for better user experience.
- Introduced new hooks for admin functionality, streamlining access checks and data loading processes.
- Enhanced documentation to reflect changes in design policies and component usage, ensuring clarity for future development.
This commit is contained in:
2026-03-06 17:51:33 +03:00
parent 43cd3bbd7d
commit fa22976e75
38 changed files with 1166 additions and 512 deletions

View File

@@ -8,7 +8,6 @@
"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";
@@ -33,6 +32,9 @@ import {
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). */
@@ -41,25 +43,30 @@ export interface CurrentDutyViewProps {
openedFromPin?: boolean;
}
type ViewState = "loading" | "error" | "accessDenied" | "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 setAppContentReady = useAppStore((s) => s.setAppContentReady);
const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading");
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [accessDeniedDetail, setAccessDeniedDetail] = useState<string | 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) => {
setLoading();
const today = new Date();
const from = localDateString(today);
const to = from;
@@ -69,7 +76,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (signal?.aborted) return;
const active = findCurrentDuty(duties);
setDuty(active);
setState("ready");
setSuccess();
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
@@ -78,19 +85,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
} catch (e) {
if (signal?.aborted) return;
if (e instanceof AccessDeniedError) {
setState("accessDenied");
setAccessDeniedDetail(e.serverDetail ?? null);
setAccessDenied(e.serverDetail ?? null);
setDuty(null);
setRemaining(null);
} else {
setState("error");
setErrorMessage(t("error_generic"));
setError(t("error_generic"));
setDuty(null);
setRemaining(null);
}
}
},
[initDataRaw, lang, t]
[initDataRaw, lang, t, setLoading, setSuccess, setAccessDenied, setError]
);
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
@@ -100,12 +105,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => controller.abort();
}, [loadTodayDuties]);
// Mark content ready when data is loaded or error, so page can call ready() and show content.
useEffect(() => {
if (state !== "loading") {
setAppContentReady(true);
}
}, [state, setAppContentReady]);
useScreenReady(!isLoading);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
@@ -116,47 +116,21 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
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]);
useTelegramBackButton({
enabled: true,
onClick: onBack,
});
const handleBack = () => {
triggerHapticLight();
onBack();
};
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
const handleClose = () => {
triggerHapticLight();
if (closeMiniApp.isAvailable()) {
closeMiniApp();
} else {
onBack();
}
closeMiniAppOrFallback();
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
@@ -165,7 +139,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") {
if (isLoading) {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
@@ -203,10 +177,10 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
);
}
if (state === "accessDenied") {
if (isAccessDenied) {
return (
<AccessDeniedScreen
serverDetail={accessDeniedDetail}
serverDetail={requestState.accessDeniedDetail}
primaryAction="back"
onBack={handlePrimaryAction}
openedFromPin={openedFromPin}
@@ -214,17 +188,16 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
);
}
if (state === "error") {
if (isError) {
const handleRetry = () => {
triggerHapticLight();
setState("loading");
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">{errorMessage}</p>
<p className="text-error">{requestState.error}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button