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:
63
webapp-next/src/hooks/use-app-init.ts
Normal file
63
webapp-next/src/hooks/use-app-init.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Application initialization: language sync, access-denied logic, deep link routing.
|
||||
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
|
||||
export interface UseAppInitParams {
|
||||
/** Whether the user is allowed (localhost or has valid initData). */
|
||||
isAllowed: boolean;
|
||||
/** Telegram Mini App start_param (e.g. "duty" for current duty deep link). */
|
||||
startParam: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs language from backend config, applies document lang/title, handles access denied
|
||||
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
||||
*/
|
||||
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||
const setLang = useAppStore((s) => s.setLang);
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||
const setLoading = useAppStore((s) => s.setLoading);
|
||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sync lang from backend config (window.__DT_LANG).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setLang(getLang());
|
||||
}, [setLang]);
|
||||
|
||||
// Apply lang to document (title and html lang) for accessibility and i18n.
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = lang;
|
||||
document.title = t("app.title");
|
||||
}, [lang, t]);
|
||||
|
||||
// When not allowed (no initData and not localhost), show access denied after delay.
|
||||
useEffect(() => {
|
||||
if (isAllowed) {
|
||||
setAccessDenied(false);
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
setAccessDenied(true);
|
||||
setLoading(false);
|
||||
}, RETRY_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [isAllowed, setAccessDenied, setLoading]);
|
||||
|
||||
// When opened via deep link startParam=duty, show current duty view first.
|
||||
useEffect(() => {
|
||||
if (startParam === "duty") setCurrentView("currentDuty");
|
||||
}, [startParam, setCurrentView]);
|
||||
}
|
||||
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 60-second interval to refresh duty list when viewing the current month.
|
||||
* Replaces state.todayRefreshInterval from webapp/js/main.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
||||
|
||||
/**
|
||||
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
|
||||
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
||||
*/
|
||||
export function useAutoRefresh(
|
||||
refresh: () => void,
|
||||
isCurrentMonth: boolean
|
||||
): void {
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentMonth) return;
|
||||
refreshRef.current();
|
||||
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [isCurrentMonth]);
|
||||
}
|
||||
34
webapp-next/src/hooks/use-media-query.ts
Normal file
34
webapp-next/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Returns true when the given media query matches (e.g. min-width: 640px for desktop).
|
||||
* Used to switch DayDetail between Popover (desktop) and Sheet (mobile).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Match a media query string (e.g. "(min-width: 640px)").
|
||||
* Returns undefined during SSR to avoid hydration mismatch; client gets the real value.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean | undefined {
|
||||
const [matches, setMatches] = useState<boolean | undefined>(() =>
|
||||
typeof window === "undefined" ? undefined : window.matchMedia(query).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
setMatches(mq.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** True when viewport is at least 640px (desktop). Undefined during SSR. */
|
||||
export function useIsDesktop(): boolean | undefined {
|
||||
return useMediaQuery("(min-width: 640px)");
|
||||
}
|
||||
182
webapp-next/src/hooks/use-month-data.ts
Normal file
182
webapp-next/src/hooks/use-month-data.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Fetches duties and calendar events for the current month. Handles loading, error,
|
||||
* access denied, and retry after ACCESS_DENIED. Replaces loadMonth() from webapp/js/main.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "@/lib/api";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
getMonday,
|
||||
localDateString,
|
||||
dutyOverlapsLocalRange,
|
||||
} from "@/lib/date-utils";
|
||||
import { RETRY_AFTER_ACCESS_DENIED_MS, RETRY_AFTER_ERROR_MS, MAX_GENERAL_RETRIES } from "@/lib/constants";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { translate } from "@/i18n/messages";
|
||||
|
||||
export interface UseMonthDataOptions {
|
||||
/** Telegram init data string for API auth. When undefined, no fetch (unless isLocalhost). */
|
||||
initDataRaw: string | undefined;
|
||||
/** When true, fetch runs for the current month. When false, no fetch (e.g. access not allowed). */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches duties and calendar events for store.currentMonth when enabled.
|
||||
* Cancels in-flight request when month changes or component unmounts.
|
||||
* On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS.
|
||||
* Returns retry() to manually trigger a reload.
|
||||
*
|
||||
* The load callback is stabilized (empty dependency array) and reads latest
|
||||
* options from a ref and currentMonth/lang from Zustand getState(), so the
|
||||
* effect that calls load only re-runs when enabled, currentMonth, lang, or
|
||||
* initDataRaw actually change.
|
||||
*/
|
||||
export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
|
||||
const { initDataRaw, enabled } = options;
|
||||
|
||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const initDataRetriedRef = useRef(false);
|
||||
const generalRetryCountRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const optionsRef = useRef({ initDataRaw, enabled, lang });
|
||||
optionsRef.current = { initDataRaw, enabled, lang };
|
||||
|
||||
const loadRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current;
|
||||
if (!enabledOpt) return;
|
||||
const initData = initDataRawOpt ?? "";
|
||||
if (!initData && typeof window !== "undefined") {
|
||||
const h = window.location.hostname;
|
||||
if (h !== "localhost" && h !== "127.0.0.1" && h !== "") return;
|
||||
}
|
||||
|
||||
const store = useAppStore.getState();
|
||||
const currentMonthNow = store.currentMonth;
|
||||
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
abortRef.current = new AbortController();
|
||||
const signal = abortRef.current.signal;
|
||||
|
||||
store.batchUpdate({
|
||||
accessDenied: false,
|
||||
accessDeniedDetail: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const first = firstDayOfMonth(currentMonthNow);
|
||||
const start = getMonday(first);
|
||||
const gridEnd = new Date(start);
|
||||
gridEnd.setDate(gridEnd.getDate() + 41);
|
||||
const from = localDateString(start);
|
||||
const to = localDateString(gridEnd);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
logger.debug("Loading month", from, to);
|
||||
const [duties, events] = await Promise.all([
|
||||
fetchDuties(from, to, initData, langOpt, signal),
|
||||
fetchCalendarEvents(from, to, initData, langOpt, signal),
|
||||
]);
|
||||
|
||||
const last = lastDayOfMonth(currentMonthNow);
|
||||
const firstKey = localDateString(first);
|
||||
const lastKey = localDateString(last);
|
||||
const dutiesInMonth = duties.filter((d) =>
|
||||
dutyOverlapsLocalRange(d, firstKey, lastKey)
|
||||
);
|
||||
|
||||
useAppStore.getState().batchUpdate({
|
||||
duties: dutiesInMonth,
|
||||
calendarEvents: events,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError") return;
|
||||
if (e instanceof AccessDeniedError) {
|
||||
logger.warn("Access denied in loadMonth", e.serverDetail);
|
||||
useAppStore.getState().batchUpdate({
|
||||
accessDenied: true,
|
||||
accessDeniedDetail: e.serverDetail ?? null,
|
||||
loading: false,
|
||||
});
|
||||
if (!initDataRetriedRef.current) {
|
||||
initDataRetriedRef.current = true;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (mountedRef.current) loadRef.current();
|
||||
}, RETRY_AFTER_ACCESS_DENIED_MS);
|
||||
retryTimeoutRef.current = timeoutId;
|
||||
}
|
||||
return;
|
||||
}
|
||||
logger.error("Load month failed", e);
|
||||
if (generalRetryCountRef.current < MAX_GENERAL_RETRIES && mountedRef.current) {
|
||||
generalRetryCountRef.current++;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (mountedRef.current) loadRef.current();
|
||||
}, RETRY_AFTER_ERROR_MS);
|
||||
retryTimeoutRef.current = timeoutId;
|
||||
return;
|
||||
}
|
||||
useAppStore.getState().batchUpdate({
|
||||
error: translate(langOpt, "error_generic"),
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
loadRef.current = load;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
initDataRetriedRef.current = false;
|
||||
generalRetryCountRef.current = 0;
|
||||
load();
|
||||
return () => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
};
|
||||
}, [enabled, load, currentMonth, lang, initDataRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
const { duties, loading: isLoading, error: hasError } = useAppStore.getState();
|
||||
if (duties.length === 0 && !isLoading && !hasError) {
|
||||
generalRetryCountRef.current = 0;
|
||||
loadRef.current();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}, [enabled]);
|
||||
|
||||
return { retry: load };
|
||||
}
|
||||
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Toggles an "is-scrolled" class on the sticky element when the user has scrolled.
|
||||
* Replaces bindStickyScrollShadow from webapp/js/main.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const IS_SCROLLED_CLASS = "is-scrolled";
|
||||
|
||||
/**
|
||||
* Listens to window scroll and toggles the class "is-scrolled" on the given element
|
||||
* when window.scrollY > 0. Uses passive scroll listener.
|
||||
*/
|
||||
export function useStickyScroll(
|
||||
elementRef: React.RefObject<HTMLElement | null>
|
||||
): void {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => {
|
||||
rafRef.current = null;
|
||||
const scrolled = typeof window !== "undefined" && window.scrollY > 0;
|
||||
el.classList.toggle(IS_SCROLLED_CLASS, scrolled);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [elementRef]);
|
||||
}
|
||||
67
webapp-next/src/hooks/use-swipe.ts
Normal file
67
webapp-next/src/hooks/use-swipe.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Touch swipe detection for horizontal month navigation.
|
||||
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export interface UseSwipeOptions {
|
||||
/** Minimum horizontal distance (px) to count as swipe. Default 50. */
|
||||
threshold?: number;
|
||||
/** When true, swipe handlers are not attached. */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
|
||||
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
|
||||
*/
|
||||
export function useSwipe(
|
||||
elementRef: React.RefObject<HTMLElement | null>,
|
||||
onSwipeLeft: () => void,
|
||||
onSwipeRight: () => void,
|
||||
options: UseSwipeOptions = {}
|
||||
): void {
|
||||
const { threshold = 50, disabled = false } = options;
|
||||
const startX = useRef(0);
|
||||
const startY = useRef(0);
|
||||
const onSwipeLeftRef = useRef(onSwipeLeft);
|
||||
const onSwipeRightRef = useRef(onSwipeRight);
|
||||
onSwipeLeftRef.current = onSwipeLeft;
|
||||
onSwipeRightRef.current = onSwipeRight;
|
||||
|
||||
useEffect(() => {
|
||||
const el = elementRef.current;
|
||||
if (!el || disabled) return;
|
||||
|
||||
const handleStart = (e: TouchEvent) => {
|
||||
if (e.changedTouches.length === 0) return;
|
||||
const t = e.changedTouches[0];
|
||||
startX.current = t.clientX;
|
||||
startY.current = t.clientY;
|
||||
};
|
||||
|
||||
const handleEnd = (e: TouchEvent) => {
|
||||
if (e.changedTouches.length === 0) return;
|
||||
const t = e.changedTouches[0];
|
||||
const deltaX = t.clientX - startX.current;
|
||||
const deltaY = t.clientY - startY.current;
|
||||
if (Math.abs(deltaX) <= threshold) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
||||
if (deltaX > threshold) {
|
||||
onSwipeRightRef.current();
|
||||
} else if (deltaX < -threshold) {
|
||||
onSwipeLeftRef.current();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener("touchstart", handleStart, { passive: true });
|
||||
el.addEventListener("touchend", handleEnd, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener("touchstart", handleStart);
|
||||
el.removeEventListener("touchend", handleEnd);
|
||||
};
|
||||
}, [elementRef, disabled, threshold]);
|
||||
}
|
||||
50
webapp-next/src/hooks/use-telegram-auth.test.ts
Normal file
50
webapp-next/src/hooks/use-telegram-auth.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Unit tests for use-telegram-auth: isLocalhost.
|
||||
* Ported from webapp/js/auth.test.js. getInitData is handled by SDK in the hook.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { isLocalhost } from "./use-telegram-auth";
|
||||
|
||||
describe("isLocalhost", () => {
|
||||
const origLocation = window.location;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: origLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true for localhost", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, hostname: "localhost" },
|
||||
writable: true,
|
||||
});
|
||||
expect(isLocalhost()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 127.0.0.1", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, hostname: "127.0.0.1" },
|
||||
writable: true,
|
||||
});
|
||||
expect(isLocalhost()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for empty hostname", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, hostname: "" },
|
||||
writable: true,
|
||||
});
|
||||
expect(isLocalhost()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for other hostnames", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, hostname: "example.com" },
|
||||
writable: true,
|
||||
});
|
||||
expect(isLocalhost()).toBe(false);
|
||||
});
|
||||
});
|
||||
61
webapp-next/src/hooks/use-telegram-auth.ts
Normal file
61
webapp-next/src/hooks/use-telegram-auth.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
retrieveRawInitData,
|
||||
retrieveLaunchParams,
|
||||
} from "@telegram-apps/sdk-react";
|
||||
import { getStartParamFromUrl } from "@/lib/launch-params";
|
||||
|
||||
/**
|
||||
* Whether the app is running on localhost (dev without Telegram).
|
||||
*/
|
||||
export function isLocalhost(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const h = window.location.hostname;
|
||||
return h === "localhost" || h === "127.0.0.1" || h === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram auth and launch context for API and deep links.
|
||||
* Replaces webapp/js/auth.js getInitData, isLocalhost, and startParam detection.
|
||||
*
|
||||
* Uses imperative retrieveRawInitData/retrieveLaunchParams in useEffect so that
|
||||
* non-Telegram environments (e.g. browser) do not throw during render.
|
||||
* start_param is also read from URL (search/hash) as fallback when SDK is delayed.
|
||||
*
|
||||
* - initDataRaw: string for X-Telegram-Init-Data header (undefined when not in TWA)
|
||||
* - startParam: deep link param (e.g. "duty" for current duty view)
|
||||
* - isLocalhost: true when hostname is localhost/127.0.0.1 for dev without Telegram
|
||||
*/
|
||||
export function useTelegramAuth(): {
|
||||
initDataRaw: string | undefined;
|
||||
startParam: string | undefined;
|
||||
isLocalhost: boolean;
|
||||
} {
|
||||
const urlStartParam = useMemo(() => getStartParamFromUrl(), []);
|
||||
const [initDataRaw, setInitDataRaw] = useState<string | undefined>(undefined);
|
||||
const [startParam, setStartParam] = useState<string | undefined>(urlStartParam);
|
||||
|
||||
const localhost = useMemo(() => isLocalhost(), []);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const raw = retrieveRawInitData();
|
||||
setInitDataRaw(raw ?? undefined);
|
||||
const lp = retrieveLaunchParams();
|
||||
const param =
|
||||
typeof lp.start_param === "string" ? lp.start_param : urlStartParam;
|
||||
setStartParam(param ?? urlStartParam ?? undefined);
|
||||
} catch {
|
||||
setInitDataRaw(undefined);
|
||||
setStartParam(urlStartParam ?? undefined);
|
||||
}
|
||||
}, [urlStartParam]);
|
||||
|
||||
return {
|
||||
initDataRaw,
|
||||
startParam,
|
||||
isLocalhost: localhost,
|
||||
};
|
||||
}
|
||||
140
webapp-next/src/hooks/use-telegram-theme.test.ts
Normal file
140
webapp-next/src/hooks/use-telegram-theme.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Unit tests for useTelegramTheme, getFallbackScheme, and applyTheme.
|
||||
* Ported from webapp/js/theme.test.js (getTheme, applyTheme).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import {
|
||||
useTelegramTheme,
|
||||
getFallbackScheme,
|
||||
applyTheme,
|
||||
} from "./use-telegram-theme";
|
||||
|
||||
vi.mock("@telegram-apps/sdk-react", () => ({
|
||||
useSignal: vi.fn(() => undefined),
|
||||
isThemeParamsDark: vi.fn(),
|
||||
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
||||
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
||||
}));
|
||||
|
||||
describe("getFallbackScheme", () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
window.getComputedStyle = originalGetComputedStyle;
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns dark when prefers-color-scheme is dark", () => {
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)",
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
expect(getFallbackScheme()).toBe("dark");
|
||||
});
|
||||
|
||||
it("returns light when prefers-color-scheme is light", () => {
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: light)",
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
expect(getFallbackScheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("uses --tg-color-scheme when set on document", () => {
|
||||
window.getComputedStyle = vi.fn(() =>
|
||||
Object.assign(
|
||||
{},
|
||||
{
|
||||
getPropertyValue: (prop: string) =>
|
||||
prop === "--tg-color-scheme" ? " light " : "",
|
||||
}
|
||||
)
|
||||
) as unknown as typeof window.getComputedStyle;
|
||||
expect(getFallbackScheme()).toBe("light");
|
||||
});
|
||||
|
||||
it("uses --tg-color-scheme dark when set", () => {
|
||||
window.getComputedStyle = vi.fn(() =>
|
||||
Object.assign(
|
||||
{},
|
||||
{
|
||||
getPropertyValue: (prop: string) =>
|
||||
prop === "--tg-color-scheme" ? "dark" : "",
|
||||
}
|
||||
)
|
||||
) as unknown as typeof window.getComputedStyle;
|
||||
expect(getFallbackScheme()).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTheme", () => {
|
||||
afterEach(() => {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sets data-theme to given scheme", () => {
|
||||
applyTheme("light");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
applyTheme("dark");
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("resolves scheme via getFallbackScheme when no argument", () => {
|
||||
window.matchMedia = vi.fn((query: string) => ({
|
||||
matches: query === "(prefers-color-scheme: dark)",
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
onchange: null,
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
applyTheme();
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTelegramTheme", () => {
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const originalGetComputedStyle = window.getComputedStyle;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
window.getComputedStyle = originalGetComputedStyle;
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("sets data-theme to dark when useSignal returns true", async () => {
|
||||
const { useSignal } = await import("@telegram-apps/sdk-react");
|
||||
vi.mocked(useSignal).mockReturnValue(true);
|
||||
renderHook(() => useTelegramTheme());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("sets data-theme to light when useSignal returns false", async () => {
|
||||
const { useSignal } = await import("@telegram-apps/sdk-react");
|
||||
vi.mocked(useSignal).mockReturnValue(false);
|
||||
renderHook(() => useTelegramTheme());
|
||||
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
|
||||
});
|
||||
});
|
||||
89
webapp-next/src/hooks/use-telegram-theme.ts
Normal file
89
webapp-next/src/hooks/use-telegram-theme.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
useSignal,
|
||||
isThemeParamsDark,
|
||||
setMiniAppBackgroundColor,
|
||||
setMiniAppHeaderColor,
|
||||
} from "@telegram-apps/sdk-react";
|
||||
|
||||
/**
|
||||
* Resolves color scheme when Telegram theme is not available (SSR or non-TWA).
|
||||
* Uses --tg-color-scheme (if set by Telegram) then prefers-color-scheme.
|
||||
*/
|
||||
export function getFallbackScheme(): "dark" | "light" {
|
||||
if (typeof window === "undefined") return "dark";
|
||||
try {
|
||||
const cssScheme = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--tg-color-scheme")
|
||||
.trim();
|
||||
if (cssScheme === "light" || cssScheme === "dark") return cssScheme;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure --surface differs from --bg so cards/cells are visible.
|
||||
* iOS OLED sends secondary_bg === bg (#000000) while section_bg differs;
|
||||
* PC desktop sends section_bg === bg (#17212b) while secondary_bg differs.
|
||||
* When the CSS-resolved --surface equals --bg, override with whichever
|
||||
* Telegram color provides contrast, or a synthesized lighter fallback.
|
||||
*/
|
||||
export function fixSurfaceContrast(): void {
|
||||
const root = document.documentElement;
|
||||
const cs = getComputedStyle(root);
|
||||
const bg = cs.getPropertyValue("--bg").trim();
|
||||
const surface = cs.getPropertyValue("--surface").trim();
|
||||
if (!bg || !surface || bg !== surface) return;
|
||||
const sectionBg = cs.getPropertyValue("--tg-theme-section-bg-color").trim();
|
||||
if (sectionBg && sectionBg !== bg) {
|
||||
root.style.setProperty("--surface", sectionBg);
|
||||
return;
|
||||
}
|
||||
const secondaryBg = cs.getPropertyValue("--tg-theme-secondary-bg-color").trim();
|
||||
if (secondaryBg && secondaryBg !== bg) {
|
||||
root.style.setProperty("--surface", secondaryBg);
|
||||
return;
|
||||
}
|
||||
root.style.setProperty("--surface", `color-mix(in srgb, ${bg}, white 8%)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies theme: sets data-theme, forces reflow, fixes surface contrast,
|
||||
* then Mini App background/header.
|
||||
* Shared by TelegramProvider (initial + delayed) and useTelegramTheme.
|
||||
* @param scheme - If provided, use it; otherwise resolve via getFallbackScheme().
|
||||
*/
|
||||
export function applyTheme(scheme?: "dark" | "light"): void {
|
||||
const resolved = scheme ?? getFallbackScheme();
|
||||
document.documentElement.setAttribute("data-theme", resolved);
|
||||
void document.documentElement.offsetHeight; // force reflow so WebView repaints
|
||||
fixSurfaceContrast();
|
||||
if (setMiniAppBackgroundColor.isAvailable()) {
|
||||
setMiniAppBackgroundColor("bg_color");
|
||||
}
|
||||
if (setMiniAppHeaderColor.isAvailable()) {
|
||||
setMiniAppHeaderColor("bg_color");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Telegram theme params to data-theme and Mini App background/header.
|
||||
* Subscribes to theme changes via SDK signals.
|
||||
* Ported from webapp/js/theme.js applyTheme / initTheme.
|
||||
*/
|
||||
export function useTelegramTheme(): "dark" | "light" {
|
||||
const signalDark = useSignal(isThemeParamsDark);
|
||||
const isDark =
|
||||
typeof signalDark === "boolean" ? signalDark : getFallbackScheme() === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(isDark ? "dark" : "light");
|
||||
}, [isDark]);
|
||||
|
||||
return isDark ? "dark" : "light";
|
||||
}
|
||||
Reference in New Issue
Block a user