feat: enhance theme handling and layout components for Telegram Mini App

- 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.
This commit is contained in:
2026-03-06 16:48:24 +03:00
parent 76bff6dc05
commit 40e2b5adc4
25 changed files with 396 additions and 131 deletions

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for openTelegramProfile: Mini Appfriendly Telegram link opening.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const openTelegramLinkMock = vi.fn();
const isAvailableFn = vi.fn().mockReturnValue(true);
vi.mock("@telegram-apps/sdk-react", () => ({
openTelegramLink: Object.assign(openTelegramLinkMock, {
isAvailable: () => isAvailableFn(),
}),
}));
describe("openTelegramProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
isAvailableFn.mockReturnValue(true);
});
it("calls openTelegramLink with t.me URL when SDK is available", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("alice");
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/alice");
});
it("strips leading @ from username", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("@bob");
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/bob");
});
it("does nothing when username is empty", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("");
openTelegramProfile(null);
openTelegramProfile(undefined);
expect(openTelegramLinkMock).not.toHaveBeenCalled();
});
it("falls back to window.open when SDK is unavailable", async () => {
isAvailableFn.mockReturnValue(false);
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
vi.resetModules();
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("alice");
expect(openSpy).toHaveBeenCalledWith(
"https://t.me/alice",
"_blank",
"noopener,noreferrer"
);
openSpy.mockRestore();
});
});

View File

@@ -0,0 +1,28 @@
/**
* Opens a Telegram profile (t.me/username) in a Mini Appfriendly way.
* Uses SDK openTelegramLink() when available so the link opens inside Telegram;
* otherwise falls back to window.open.
*/
import { openTelegramLink } from "@telegram-apps/sdk-react";
/**
* Opens the given Telegram username profile. When running inside the Mini App,
* uses openTelegramLink() so the link opens in-app; otherwise opens in a new tab.
* Safe to call from click handlers; does not throw.
*/
export function openTelegramProfile(username: string | null | undefined): void {
const clean = username ? String(username).trim().replace(/^@+/, "") : "";
if (!clean) return;
if (typeof window === "undefined") return;
const url = `https://t.me/${encodeURIComponent(clean)}`;
try {
if (openTelegramLink.isAvailable()) {
openTelegramLink(url);
return;
}
} catch {
// SDK not available or not in Mini App context.
}
window.open(url, "_blank", "noopener,noreferrer");
}

View File

@@ -0,0 +1,7 @@
/**
* Inline script for first-paint theme: hash (tgWebAppColorScheme + themeParams),
* then Telegram WebApp, then CSS --tg-color-scheme, then prefers-color-scheme.
* Sets data-theme and Mini App bg/header colors. Shared by layout and global-error.
*/
export const THEME_BOOTSTRAP_SCRIPT =
"(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();";